Самописец давления в гараже я собирал по уроку про график датчика: кольцевой буфер, полилиния, обновление раз в секунду. Работал он безотказно, но шкала была пришита намертво — map на весь ход АЦП, 0-1023 в высоту экрана. Рабочий ход датчика — пять-шесть единиц, и кривая лежала в трёх нижних строках плоской чертой: события на ней были, разглядеть их было нельзя.
Лекарство — живая шкала: минимум и максимум пересчитываются по окну истории, и кривая каждый кадр растягивается ровно на высоту графика. Тот же трюк встроен в Serial Plotter с рождения, но плоттер ПК живёт за компьютером, а прибор на стене гаража — сам по себе. Здесь разберу арифметику автоскейла, сетку с честной ценой деления и среднее по окну — плюс граблю, на которой среднее уезжает в минус.
Каркас самописца менять не будем: кольцевой буфер и планирование по millis остаются как были. Нового — скан min/max, вертикали сетки, черта среднего и кнопка, которая временно пришпиливает шкалу к фиксированному диапазону, чтобы разницу почувствовала рука.
Живую шкалу я сначала недооценил и городил её поверх готового графика отдельным слоем правок — вышло громоздко и глючно: правки лезли в три места кадра. Вторая версия, при которой скан краёв и пересчёт сидят в начале risujKadr, заняла двадцать строк и работает без правок год. Мораль простая: масштаб — это часть отрисовки, а не надстройка над ней, и место ему — в начале функции кадра.
Автоскейл: скан окна и мёртвая зона
Автоскейл — один проход по буферу перед каждым кадром: два сравнения на точку, для 64 точек — сотня с копейками операций, микросекунды. Дешевле не бывает, поэтому сканировать можно хоть каждый кадр. Дальше два решения, и оба важно принять осознанно. Первое — мёртвая зона: край шкалы переезжает только когда сигнал ушёл за него на три-пять единиц, иначе на ровной линии шкала дышит от каждого пикселя шума и график выглядит дрожащим. Плавный подгон краёв мы уже проходили в уроке про ленту точек с автоскейлом — там же приём с задержкой сжатия.
Второе — цена деления. Вертикальная сетка каждые 16 пикселей даёт восемь колонок, и подпись шкалы обязана быть круглой: живой диапазон 12-74 единицы не подписывают, его округляют до 10-80 и делят на восемь — по 8-10 единиц на клетку. Глаз читает график только через круглые шаги; подпись «7,5 ед/клетка» я себе запрещаю: через месяц никто, включая автора, не расшифрует.
Базовую линию прижимайте к y=62, а не к 63: ось y растёт вниз, и нижняя строка уходит под подписи — базовый шрифт 6×8 занимает ровно строку. Подписи ставьте фактические, по краям окна: шкала, которая гордо пишет 0 и 1023 при живом диапазоне 12-74, врёт по-крупному — вся точность графика стоит не дороже подписей под ним.
Шаг между точками подбирайте вместе с длиной буфера: 64 точки на 128 пикселей — шаг два и сплошная кривая; 32 точки — шаг четыре с просветами, и глаз читает такой график как ломаную, а не как линию. Двойной шаг на тот же буфер растягивает окно вдвое без потери читаемости, но точки перестают быть событиями и становятся трендом — выбирайте, что вам нужно увидеть: скачок или ход процесса.
| Буфер | ОЗУ под int | Окно при 500 мс | Чем оборачивается |
|---|---|---|---|
| 16 точек | 32 байта | 8 секунд | живая реакция, шумит сильнее |
| 32 точки | 64 байта | 16 секунд | мой баланс для температуры комнаты |
| 64 точки | 128 байт | 32 секунды | кадр 25-30 мс, шина терпит два кадра в секунду |
| 128 точек | 256 байт | больше минуты | четверть ОЗУ Uno — уже жадно, буферу дисплея тесно |
Сетка, черта среднего и переполнение
Вертикали сетки — drawLine(x, 2, x, 61) каждые 16 пикселей: четыре линии на экран, и график от них только выигрывает, глазу нужен масштаб, как линейка на чертеже. Горизонтальную сетку я не ставлю — полосы сливаются с кривой; вместо неё черта среднего: одна drawLine по высоте суммы окна, и на ней сразу видно, уходит ли процесс от нормы. При плотном шаге в один пиксель линии становятся толще кривой — тогда переходите на drawPixel и рисуйте точки: они честнее показывают плотность выборки, осциллограф из урока про analogRead построен ровно так.
Пара слов о том, чего сетка не прощает. Линии ставьте тусклее кривой визуально — в монохроме это значит короче: сетку от y=6 до y=58, кривую во всю высоту 2-61. Пиксель, где сетка и кривая совпали, всё равно белый, и глаз читает совпадение как узел на линейке — это к лучшему. А вот подписи поверх сетки я ставлю только в нижней строке: в поле графика любой текст — это место, где кривую уже не видно.
Среднее — полезная и коварная штука. Считается оно суммой окна, а int на Uno — 16 бит со стеклом в 32767: 33 замера по 1000 уже выбивают дно, а полный буфер АЦП-значений — 64 × 1023 = 65472 — пробивает его дважды. Сумма уезжает в минус, черта среднего рисуется под базой, и выглядит это как сбой графика, хотя график ни при чём. Считайте в long: четыре байта вместо двух, и тема закрыта; если экономите каждый байт, держите точки в byte и складывайте всё равно в long.
Порядок внутри кадра держите постоянным: скан краёв, сетка, черта среднего, кривая, подписи, display(). Перестановка шагов скетч не сломает, но кривая, проложенная после подписей, затрёт их линиями: в одно-битном буфере белый пиксель поверх белого не различить, и строка min-max превращается в кашу. У меня порядок кадра выбит в комментариях над risujKadr — соседи по проекту читают его как оглавление.
#include <Wire.h>#include <Adafruit_GFX.h>#include <Adafruit_SSD1306.h> Adafruit_SSD1306 display(128, 64, &Wire, -1); const int N = 64; // 64 tochki s shagom 2 px - vsya shirinaint buf[N];int golova = 0; // kuda pishem sleduyushchuyu tochkuint zapolneno = 0;int avto = 1; // 1 = zhivaya shkala, 0 = fiks 0-1023int vmin = 0, vmax = 1023; // tekushchie kraya shkalylong tKadr = 0;int lastKn = HIGH;unsigned long tKlik = 0; void zapis(int v) { buf[golova] = v; golova = (golova + 1) % N; if (zapolneno < N) zapolneno++;} int nachalo() { if (zapolneno < N) return 0; return golova; // polnyy bufer: starshaya tochka v golove} int yTochki(int v) { int lo = 0, hi = 1023; if (avto == 1) { lo = vmin; hi = vmax; } if (hi == lo) hi = lo + 1; // zaschita ot deleniya na nol return map(v, lo, hi, 61, 3);} void risujKadr() { display.clearDisplay(); // setka: vertikali cherez kazhdye 16 px, cena deleniya - diapazon / 8 for (int x = 16; x < 128; x += 16) { display.drawLine(x, 2, x, 61, SSD1306_WHITE); } int start = nachalo(); // kraya i sredneye: skan po oknu, summa tolko v long! int lo = buf[start], hi = buf[start]; long summa = 0; for (int i = 0; i < zapolneno; i++) { int v = buf[(start + i) % N]; if (v < lo) lo = v; if (v > hi) hi = v; summa += v; } int sred = (int)(summa / zapolneno); if (avto == 1) { if (abs(lo - vmin) > 3) vmin = lo; // mertvaya zona 3 edinicy if (abs(hi - vmax) > 3) vmax = hi; } // cherta srednego int ySr = yTochki(sred); display.drawLine(0, ySr, 127, ySr, SSD1306_WHITE); // krivaya: poliliniya iz drawLine mezhdu sosednimi tochkami int yPred = yTochki(buf[start]); for (int i = 1; i < zapolneno; i++) { int x = i * 2; int y = yTochki(buf[(start + i) % N]); display.drawLine(x - 2, yPred, x, y, SSD1306_WHITE); yPred = y; } // podpisi: fakticheskie kraya, ne 0-1023 display.setTextSize(1); display.setTextColor(SSD1306_WHITE); display.setCursor(0, 56); if (avto == 1) { display.print(vmin); display.print("-"); display.print(vmax); } else { display.print("0-1023 fiks"); } display.print(" sr:"); display.print(sred); display.display();} void setup() { Serial.begin(9600); pinMode(2, INPUT_PULLUP); display.begin(SSD1306_SWITCHCAPVCC, 0x3C); display.setTextColor(SSD1306_WHITE);} void loop() { int kn = digitalRead(2); if (kn != lastKn && millis() - tKlik > 200) { tKlik = millis(); lastKn = kn; if (kn == LOW) { avto = 1 - avto; if (avto == 0) { vmin = 0; vmax = 1023; } risujKadr(); } } long t = millis(); if (t - tKadr >= 500) { tKadr = t; zapis(analogRead(A0)); risujKadr(); Serial.print("v="); Serial.println(buf[(golova + N - 1) % N]); } delay(2);}Кольцевой указатель здесь экономит и время, и нервы: новая точка пишется в голову, указатель крутится по модулю N, и сдвигать массив циклом не нужно никогда. Для отрисовки при полном буфере старейшая точка сидит как раз в голове — обход (golova + i) % N даёт хронологию слева направо. Пока буфер не заполнен, рисуем только накопленное: график растёт слева, как и ожидает глаз. Обновление по millis держит кадр раз в 500 мс: передача буфера занимает 25-30 мс, и два кадра в секунду съедают около пяти процентов шины — соседям по I2C хватает. Планировщик тот же, что всегда: millis вместо delay, никакого блокирующего кода.
Кнопка на D2 заслуживает отдельного абзаца, потому что выглядит излишней: зачем фиксировать шкалу, если живая всегда лучше? Затем, что живая шкала врёт о скорости процесса: сигнал, медленно сползающий на десять единиц, на растянутой шкале выглядит обвалом. Фиксированный диапазон — это мера длины, и переключение между ними я делаю одной кнопкой, как на осциллографе: щёлк — и кривая рассказывает правду о масштабе, ещё щёлк — снова детали.
Крутите ручку медленно — кривая дышит, края окна догоняют сигнал с зажатой мёртвой зоной, подпись сверху показывает, на чём именно стоит шкала. Клик кнопки пришпиливает диапазон к 0-1023: кривая сжимается в пару строк у базы, и вот наглядный ответ на вопрос, зачем вообще автоскейл. Черта среднего двигается вдвое-втрое ленивее кривой — в этом её ценность: она показывает тренд, а не дрожь, и следить за ней стоит отдельно от звёздочек.
Обратите внимание на момент, когда буфер только заполняется: график растёт слева направо, не пытаясь растянуть три точки на всю ширину. Если хочется обратного поведения — тянуть частичное окно на весь экран, — считайте края и шаг по колонкам от zapolneno, но подписи min/max оставьте фактическими. И следите в Serial за числом v=: экран и числа всегда должны рассказывать одну историю — если черта среднего ушла под базу, а v= положительное, вы поймали переполнение int на живом приборе.
Ручка A0 в макетке стоит за любой медленный датчик: термистор, LDR, газ — что угодно с плавным ходом значений. Попробуйте сыграть в прибор: поднесите кривую к верхнему краю и держите — шкала догонит и обожмёт; резко бросьте вниз — края останутся наверху на три-пять единиц, пока мёртвая зона не поверит в новый уровень. Это не недоработка, а именно то поведение, которое делает график читаемым на реальном дрожащем сигнале.
Память на буфер тоже считается заранее: массив int на 64 точки — 128 байт, а буфер дисплея уже забрал свою тысячу из двух килобайт Uno — раскладка ОЗУ посчитана в уроке про буфер кадра. Значения 0-255 укладывайте в byte — экономия ровно вдвое, а сумма всё равно держится в long.
- 1. Буфер на 64 точки, выборка раз в 500 мс. Какое окно истории вы видите?
- 2. Сумма 64 значений АЦП (каждое до 1023) считается в переменной int на Uno. Что случится?
- 3. Шкала на ровном сигнале дёргается от каждого пикселя шума. Первое лекарство?
- 4. Живой диапазон окна — 40-90 единиц, сетка из восьми колонок. Честная цена деления?
- 5. Почему базовую линию графика прижимают к y=62, а не к 63?
- Автоскейл — скан min/max по окну каждый кадр: для 64 точек это микросекунды, дороже только подписи
- Мёртвая зона 3-5 единиц: край шкалы переезжает не от каждого пикселя шума
- Цена деления — округлённый диапазон на восемь колонок сетки; подписи шкалы — фактические края окна
- Сумму для среднего держите в long: 64 × 1023 = 65472 пробивает потолок int в 32767 дважды
- Окно истории = N точек × период выборки: 64 × 500 мс = 32 секунды при 128 байтах ОЗУ
Частые вопросы по теме урока
- Буфер на 64 точки, выборка раз в 500 мс. Какое окно истории вы видите?
- 64 × 500 мс = 32 секунды. Период выборки умножается на число точек — это и есть длина окна, независимая от ширины экрана. 32 секунды
- Сумма 64 значений АЦП (каждое до 1023) считается в переменной int на Uno. Что случится?
- int на Uno 16-битный со стеклом 32767, а сумма доходит до 65472. Считайте в long — четыре байта вместо двух. Переполнение: 65472 больше 32767, среднее уедет в минус
- Шкала на ровном сигнале дёргается от каждого пикселя шума. Первое лекарство?
- Мёртвая зона не даёт краям ходить от шума. Остальные меры не лечат причину — чувствительность к каждому новому замеру. Мёртвая зона: край шкалы переезжает только при выходе сигнала за него на 3-5 единиц
- Живой диапазон окна — 40-90 единиц, сетка из восьми колонок. Честная цена деления?
- Края округляют до круглых чисел, диапазон делят на число колонок. Подпись 6,25 ед/клетка глаз через месяц не расшифрует. Округлить края до 40-88/10-80 и делить на восемь — по 10 единиц
- Почему базовую линию графика прижимают к y=62, а не к 63?
- Строка подписей занимает свои 8 пикселей у нижнего края. База на 62 оставляет ей место, и подписи не налезают на кривую. Ось y растёт вниз: нижняя строка нужна под подписи min/max шрифтом 6x8