Газоанализатор на макетке почти работал: столбик уровня дёргался, как испуганный, последняя цифра значения мелькала пять раз в секунду, а зона тревоги то включалась, то гасла, потому что сырое значение с АЦП гуляло на пятнадцать-двадцать единиц вокруг порога. Ровно то же вы увидите с любым датчиком на analogRead: шум — не поломка, а свойство канала, и экран обязан его переживать, иначе прибор выглядит больным.
Шум имеет размах и источник: при коротких проводах и питании 5 В АЦП гуляет на десять-двадцать единиц, длинный макетный провод без нормальной земли добавляет ещё. У каждого канала свои причуды: фоторезистор врёт от наводок лампы дневного света, газовый датчик плавает первые минуты прогрева. Экран всё усиливает: то, что в Serial терпимо, на LCD выглядит нервным тиком последней цифры.
Собираем правильный индикатор из трёх частей: вертикальные столбики из своих глифов, сглаживание экспоненциальным средним и пороги с гистерезисом. Источник уровня — потенциометр на A0: крутите ручку, как будто это сигнал с датчика, вся механика при переносе на реальный сенсор не меняется вплоть до констант порогов.
Столбики из глифов
Вертикальный столбик — это клетка экрана с глифом, залитым снизу на нужную высоту: от одной строки до семи. Раскладка слотов здесь вынуждает быть хитрым: высоты 1–7 занимают слоты 1–7, нулевая высота печатается обычным пробелом, а полный столбик из восьми строк — кодом 255 из ROM. Ловушка нулевого слота, разобранная ещё в уроке про свои символы, обходит нас стороной без всяких жертв. Десять таких клеток в ряд — гистограмма истории: каждые 200 мс новое значение въезжает справа, старые уезжают влево, и тренд виден одним взглядом, как на пиковом индикаторе микшера.
Движение истории почти бесплатное: массив из десяти высот сдвигается одним циклом, и перерисовка всего ряда стоит пять-шесть миллисекунд раз в 200 мс — три процента времени. Сдвиг массива идёт до перерисовки: клетка рисуется по новому месту, гистограмма уезжает влево ровная, без хвостов. Сдвиг после отрисовки даёт знаменитую картинку с отстающим на шаг столбиком — если увидите её у себя, вы знаете, где искать.
Десять клеток истории при тике в 200 мс — это два с половиной секунды жизни индикатора: медленные процессы видны целиком, быстрые пики заметны всплеском. Одно число сообщает значение, гистограмма — направление, а для уровня сигнала направление важнее: глаз сравнивает соседние столбики быстрее, чем читает цифру. История длиннее упирается в ширину экрана — тогда честнее Serial и график на компьютере.
Каждая клетка перерисовывается отдельно — пятьдесят-шестьдесят байтов шины на весь ряд, при 100 кГц это пять-шесть миллисекунд раз в 200 мс, то есть три процента времени. Частота обновления экрана и её цена считаются так же, как в уроке про тик таймера, поэтому столбики не мерцают и не съедают loop.
Сглаживание — экспоненциальное скользящее среднее, одна строка: lvl = lvl + (raw - lvl) / 8. Новый отсчёт получает вес в один восьмой, прошлая история — семь восьмых. Инертность считается на пальцах: за k отсчётов остаток скачка равен (7/8)^k, то есть половина разницы закрывается за пять-шесть отсчётов, а после десяти от неё остаётся четверть. Деление на восемь — на степень двойки — компилятор превращает в сдвиг, дешевле не бывает. Числа для отладки удобно гнать в Serial с разбором: сырой против сглаженного рядом — и фильтр виден, как на рентгене: скачок у сырого и пологая дуга у сглаженного.
#include <Wire.h>#include <LiquidCrystal_I2C.h> LiquidCrystal_I2C lcd(0x27, 16, 2); // Столбики высотой 1-7 строк: байты снизу залиты на нужную высотуbyte h1[8] = {0b00000, 0b00000, 0b00000, 0b00000, 0b00000, 0b00000, 0b00000, 0b11111};byte h2[8] = {0b00000, 0b00000, 0b00000, 0b00000, 0b00000, 0b00000, 0b11111, 0b11111};byte h3[8] = {0b00000, 0b00000, 0b00000, 0b00000, 0b00000, 0b11111, 0b11111, 0b11111};byte h4[8] = {0b00000, 0b00000, 0b00000, 0b00000, 0b11111, 0b11111, 0b11111, 0b11111};byte h5[8] = {0b00000, 0b00000, 0b00000, 0b11111, 0b11111, 0b11111, 0b11111, 0b11111};byte h6[8] = {0b00000, 0b00000, 0b11111, 0b11111, 0b11111, 0b11111, 0b11111, 0b11111};byte h7[8] = {0b00000, 0b11111, 0b11111, 0b11111, 0b11111, 0b11111, 0b11111, 0b11111}; const int potPin = A0;const int btn = 2;const int led = 12; float lvl = 512.0; // сглаженное значениеint hist[10]; // история высот для гистограммыbool smooth = true;bool pik = false; // зона тревоги с гистерезисомunsigned long lastTick = 0;unsigned long lastLog = 0;int prevBtn = HIGH; void drawCol(int col, int h) { lcd.setCursor(col, 0); if (h == 0) lcd.print(" "); // пустая клетка - без слотов else if (h == 8) lcd.write(255); // полный столбик из ROM else lcd.write(byte(h)); // слоты 1-7} void setup() { pinMode(btn, INPUT_PULLUP); pinMode(led, OUTPUT); lcd.init(); lcd.backlight(); lcd.createChar(1, h1); lcd.createChar(2, h2); lcd.createChar(3, h3); lcd.createChar(4, h4); lcd.createChar(5, h5); lcd.createChar(6, h6); lcd.createChar(7, h7); for (int i = 0; i < 10; i++) { hist[i] = 4; drawCol(i, 4); } lcd.setCursor(11, 0); lcd.print("S"); lcd.setCursor(12, 0); lcd.print("NORM"); lcd.setCursor(0, 1); lcd.print("R= E=");} void loop() { int now = digitalRead(btn); if (prevBtn == HIGH && now == LOW) { // фильтр вкл/выкл для сравнения smooth = !smooth; lcd.setCursor(11, 0); if (smooth) lcd.print("S"); else lcd.print(" "); } prevBtn = now; if (millis() - lastTick > 200) { // основной тик индикатора lastTick = millis(); int raw = analogRead(potPin); if (smooth) lvl = lvl + (raw - lvl) / 8.0; else lvl = raw; // без фильтра - дёргается int h = map((int)lvl, 0, 1023, 0, 8); if (h < 0) h = 0; if (h > 8) h = 8; for (int i = 0; i < 9; i++) hist[i] = hist[i + 1]; hist[9] = h; for (int i = 0; i < 10; i++) drawCol(i, hist[i]); // Гистерезис: вход в тревогу на 700, выход на 650 if (!pik && (int)lvl > 700) pik = true; if (pik && (int)lvl < 650) pik = false; digitalWrite(led, pik ? HIGH : LOW); lcd.setCursor(12, 0); if (pik) lcd.print("PIK!"); else if (lvl < 300) lcd.print("SLAB"); else lcd.print("NORM"); lcd.setCursor(0, 1); lcd.print("R="); if (raw < 1000) lcd.print(" "); lcd.print(raw); lcd.setCursor(7, 1); lcd.print("E="); if ((int)lvl < 1000) lcd.print(" "); lcd.print((int)lvl); } if (millis() - lastLog > 600) { // сравнение сырья и фильтра lastLog = millis(); Serial.print("raw="); Serial.print(analogRead(potPin)); Serial.print(" lvl="); Serial.println((int)lvl); }}Пороги, которые не мигают
| Фильтр | Реакция на скачок | Дрожание экрана | Цена |
|---|---|---|---|
| Без фильтра | Мгновенно, тот же тик | Столбики прыгают на 1-2 клетки | Ноль переменных |
| EMA / 8 | Половина за 5-6 отсчётов | Тихо, ползёт плавно | Одна float-переменная |
| Медиана из 5 | Через 2 отсчёта, но целиком | Тихо даже у импульсных помех | Массив из 5 значений и сортировка |
| EMA / 64 | Половина за четыре десятка отсчётов | Мёртвый покой, реагирует с ленью | Та же одна float-переменная |
У фильтра есть слабое место на старте: переменная lvl объявлена с серединой диапазона, и первые полсекунды столбик едет с половины к реальному значению, как будто прибор оживает. Лечится инициализацией первым же чтением: в setup присвойте lvl значение analogRead, и бар открывается сразу в правильной позиции. Мелочь, но именно её замечают первые три секунды знакомства с прибором.
Инертность фильтра считается в уме: остаток скачка после k отсчётов равен (7/8)^k — пять-шесть отсчётов закрывают половину, двадцать оставляют семь процентов. Делитель — ручка настройки: четыре реагирует почти мгновенно и всё ещё дрожит, шестнадцать спокоен до вялости, восемь — золотая середина для индикатора на экране. Менять делитель стоит под живой процесс, а не под настроение: медленный датчик прощает, быстрый — нет. Кому не нужен float, тот собирает фиксированную точку: уровень в единицах по одной восьмой, деление заменяется сдвигом, точности хватает с запасом.
Порог без зазора — главная причина мигающих тревог: значение гуляет вокруг 700, зона щёлкает туда-сюда несколько раз в секунду, реле дребезжит. Лечение — два порога вместо одного: вход в тревогу выше 700, выход ниже 650. Между ними состояние не меняется вовсе, и зона спокойна, как термостат, который включается не сразу после выключения. Я беру зазор в один-два процента диапазона для дисплеев и побольше — для реле, где каждый щелчок слышно.
Зазор выбирается по процессу: два-пять процентов диапазона для дисплея, побольше — для реле и вентилятора, где каждый щелчок слышно. Гистерезис давно встроен в бытовые термостаты: нагреватель включается чуть ниже уставки и выключается чуть выше — по той же причине. Мой вентилятор обдува на фоторезисторе перестал мигать только после того, как пороги разъехались на сорок единиц АЦП — до этого он щёлкал по два раза в секунду у границы.
Комбинация фильтра и гистерезиса покрывает девять из десяти индикаторов, что я делал: термометр без мерцания на том же принципе показывает температуру, не дёргая последнюю цифру. Если же нужна реакция именно на быстрые пики — берите медиану из пяти, она не глотает одиночный выброс, в отличие от EMA, который его размажет.
Раскладка экрана здесь тоже решение, а не случайность: десять клеток истории слева, флаг фильтра и зона справа в верхней строке, числа внизу. Верхняя строка отвечает на вопрос куда, нижняя — сколько, и перепутать их нельзя. Зона напечатана четырьмя буквами — PIK, NORM, SLAB, — потому что длинные слова на шестнадцать клеток не влезают без жертв, а четыре буквы читаются с трёх метров.
Минимум и максимум канала снимаются заранее: у моего фоторезистора рабочий диапазон оказался от 80 до 920, и map по реальным границам даёт столбик на всю высоту вместо бледной палки посередине. Калибровка на три минуты делает индикатор живее любой перекраски: короткий диапазон — это бледные столбики и вечно серединная зона, хотя АЦП честно отрабатывает все десять бит.
Пара raw против lvl в нижней строке — главный учебный элемент скетча: при резком повороте ручки сырое значение прыгает сразу, сглаженное догоняет за пять-шесть тиков, и разрыв виден глазами. Скорость сближения — это и есть делитель фильтра, поднятый из формулы на экран. Без этой пары новичок верит фильтру на слово; с ней — видит цену и подбирает делитель под свой канал за минуту.
- 1. Что делает строка lvl = lvl + (raw - lvl) / 8?
- 2. Почему высоту столбика 8 печатают кодом 255, а не своим глифом?
- 3. Зачем тревоге два порога — вход на 700, выход на 650?
- 4. Что покажет целочисленная EMA с делением на 8 при малых разницах?
- 5. Какой фильтр выбрать, если важен одиночный быстрый пик?
- Вертикальный столбик — глиф высоты 1–7 в слотах 1–7; ноль — пробел, восемь — код 255 из ROM
- EMA одной строкой: lvl += (raw - lvl) / 8.0; половина скачка закрывается за 5-6 отсчётов
- Целочисленное деление в EMA застревает у цели — держите фильтр во float
- Гистерезис 700/650 держит зону тревоги от дребезга на границе
- Десять столбиков-история показывают тренд лучше любой цифры и стоят пять-шесть мс раз в 200 мс
Частые вопросы по теме урока
- Что делает строка lvl = lvl + (raw - lvl) / 8?
- Это экспоненциальное среднее: 12,5 % от нового значения плюс 87,5 % накопленной истории. Деление на степень двойки компилятор превращает в сдвиг — дешевле не бывает. Усредняет: новый отсчёт получает вес в один восьмой
- Почему высоту столбика 8 печатают кодом 255, а не своим глифом?
- Семь слотов закрывают промежуточные высоты от единицы до семёрки, полный столбик достаётся из знакогенератора бесплатно, а нулевая высота печатается пробелом без всяких слотов. Полный блок уже есть в ROM, слоты 1–7 уходят на высоты 1–7
- Зачем тревоге два порога — вход на 700, выход на 650?
- Гистерезис в 50 единиц держит состояние неизменным в полосе между порогами: значение, гуляющее у границы, больше не щёлкает зоной туда-сюда по несколько раз в секунду. Чтобы зона не мигала, пока значение гуляет у границы
- Что покажет целочисленная EMA с делением на 8 при малых разницах?
- Когда разница меньше восьми, целочисленное деление даёт ноль: фильтр останавливается в паре единиц от цели и столбик стоит. Лечится float-переменной и делением / 8.0. Застревание в одной-двух единицах от цели
- Какой фильтр выбрать, если важен одиночный быстрый пик?
- EMA размазывает одиночный выброс по нескольким отсчётам, и пик теряется, а медиана из пяти пропускает его целиком, давя при этом обычный шум фона. Медиану из пяти — она выброс не глотает