Оцифровка и тарирование графиков
Исследование аналоговых сигналов гораздо более интересно, чем расшифровка цифровых, и в этом уроке мы поговорим о такой ситуации, когда нужно оцифровать и "запомнить" в микроконтроллере аналоговый сигнал. Как и зачем это может быть нужно? Чаще всего это встречается в случаях, когда некий датчик выдаёт сигнал в зависимости от каких-то внешних условий, например термистор меняет сопротивление в зависимости от температуры, потенциометр меняет сопротивление в зависимости от угла поворота, фоторезистор меняет сопротивление в зависимости от освещённости, а датчик холла выдаёт напряжение в зависимости от интенсивности магнитного поля. Как найти измеряемую физическую величину, зная "сырой" сигнал с датчика? Правильно, нужно сообщить микроконтроллеру, какой величине какой сигнал соответствует. Такой процесс называется тарированием. В целом существует два подхода:
- Аппроксимировать соответствие между сигналом и величиной при помощи функции, то есть величина v станет функцией f от сигнала s:
v = f(s)
. Функция может быть какой угодно: линейная зависимость, квадратичная, экспоненциальная, логарифмическая и даже их смеси в виде кусочных функций. Например, зависимость между температурой и сопротивлением термистора описывается уравнением Стейнхарта-Харта, что позволяет определить температуру любого термистора, зная его характеристики и сопротивление ("сигнал").- Плюсы: получение значения вне исследованного диапазона, так как поведение графика "предсказывается" аппроксимирующей функцией. Малый размер в памяти, фактически пара строк кода с вычислениями.
- Минусы: вычисления, особенно float, логарифмы и степенные функции, занимают значительное время (десятки микросекунд).
- Создать таблицу (массив) значений сигнала и соответствующей ему величины. Проблема в том, что аналоговый сигнал непрерывен, то есть имеет условно бесконечно большое разрешение: можно разбить график на бесконечное количество точек, и каждой будет соответствовать разное значение! А память микроконтроллера у нас не резиновая =) Так что придётся ограничиться конечным разрешением оцифровки, либо использовать более хитрые трюки, о которых мы поговорим ниже.
- Плюсы: оцифровка графика любой формы, высокое соответствие с реальным значением, максимально быстрое получение значения (отсутствуют вычисления).
- Минусы: таблица занимает много места в памяти, разрешение конечное.
Поводом для создания этого урока послужил пост у нас в сообществе, где человек просил помочь с тарированием вот этих двух графиков (получены с условного аналогового датчика расстояния путём ручного измерения сигнала в нескольких точках): Эти графики идеально подходят для разбора данной темы, поэтому попробуем создать их цифровые модели обоими способами. Ниже прилагаю исходный набор точек графиков для тех, кто захочет поиграться с ними самостоятельно. Первый столбец - сигнал (ось x), второй - значение (ось y). Внимание! График на первой картинке отображён со сменой осей. Далее в уроке мы используем горизонтальную ось сигнала, а вертикальную - значения.
Аппроксимация функцией
Линейная зависимость
Первый график очень близок к линейной зависимости, поэтому можно аппроксимировать его прямой линией от первой точки до последней, то есть вот так: Эта задача решается максимально просто: в Arduino у нас есть замечательная функция map()
, которая позволяет перевести один диапазон значений в другой, и делает это как раз линейно. Напомню, что функция map()
принимает аргументы: map(значение, мин, макс, новый мин, новый макс)
. Несложно представить, что находится "внутри" этой функции: школьное уравнение прямой линии, проходящей через две точки. Мы задаём крайние точки и получаем готовую функцию, в программе это можно оформить так:
int getVal(int signal) { return map(signal, 405, 288, 10, 55); }
Как вы могли заметить, я указал диапазон значений с датчика (405, 288) и соответствующее им реальное значение (10, 55). И всё! Кстати, функция будет работать именно как уравнение прямой: если с датчика придёт значение меньше 288 или больше 405, функция вернёт величину согласно пропорции, как по красной линии на картинке выше. Как вы могли заметить, "проведение" прямой пропорциональности через крайние точки даёт не самый хороший результат: его погрешность будет неравномерной. Что делать, если график выглядит вот так, и его хочется аппроксимировать прямой, которая обеспечит наименьшее отклонение? Да, можно просто подобрать крайние точки для map()
вручную и всё. А как быть с более сложными графиками или более высокими требованиями к точности аппроксимации?
Аппроксимация в Excel
В MS Office Excel данная возможность называется линией тренда. Добавим данные в столбцы (слева сигнал, справа значение), создадим график, добавим линию тренда и выведем её уравнение на область графика: Таким образом значения величины с датчика можно будет получить при помощи функции:
float getVal(int signal) { return (-0.386 * signal + 165); }
Перейдём ко второму, более интересному графику.
"Сложная" аппроксимация в Excel
Для аппроксимации графиков другой формы можно попробовать другие варианты из предложенных. Загрузим второй график и попробуем создать линии тренда разных "типов": Упс! График не получается аппроксимировать при помощи простейших функций. Но не беда: в данном графике наблюдается чёткая смена характеристики примерно в середине, а конкретно - при сигнале 269. Давайте разделим график на два отдельных графика и попробуем аппроксимировать их по отдельности: Обе части графика удалось аппроксимировать при помощи полинома с довольно таки хорошей точностью. Итак, у нас есть два уравнения и диапазоны, в которых они "работают". Осталось добавить соответствующее условие и наша аппроксимирующая функция готова!
float getVal(int signal) { if (signal <= 269) return (0.0019 * signal * signal - 0.5117 * signal + 70.605); else return (-0.0007 * signal * signal - 0.5295 * signal - 14.978); }
Вот таким образом можно решить задачу даже для неприятного на вид графика.
Таблица соответствий
Таблица - сохранённые в памяти точки графика, позволяющие найти значение по соответствующему сигналу. С таблицей есть два варианта:
- Двухмерная таблица. Один столбец хранит сигнал, второй - соответствующее ему значение. Для сложного графика можно оптимизировать размер таблицы, сделав меньше точек на прямых участках, и больше - на участках сложной формы. Заполняется такая таблица последовательно с любым шагом, потому что в дальнейшем мы будем делать поиск по таблице. Чем больше таблица, тем дольше будет происходить поиск дальних значений (речь идёт о единицах микросекунд, не более).
- Одномерная таблица. Такая таблица заполняется с равным шагом по оси сигнала, а находятся в ней значения. Доступ к таблице осуществляется через преобразование сигнала к номеру ячейки, что позволяет получить одинаковое и минимальное время поиска для таблицы любого размера (фактически это не поиск, а выбор конкретной ячейки). Такой тип таблицы использует в два раза меньше памяти, чем двумерная таблица, а также имеет максимальную скорость доступа. Для критичных ко времени преобразований следует отдать предпочтение этому способу, ведь он будет даже быстрее, чем аппроксимация функцией.
Метод таблиц позволяет более точно и близко к реальному графику найти "значение" по "сигналу", и тут есть интересные моменты:
- Таблица, очевидно, будет занимать гораздо больше места в памяти МК. Таблицу можно и нужно разместить в PROGMEM - постоянной памяти, об этом мы говорили в соответствующем уроке и я покажу это ниже здесь.
- Табличный способ позволяет оцифровать график абсолютно любой формы. В отличие от аппроксимации функцией, его не нужно будет исследовать и разбивать на части, описываемые простейшими функциями.
- Чем сильнее мы раздробим график, то есть чем больше точек будет в таблице, тем точнее будет определение "значения" и тем меньше будет его минимальный шаг (см. картинку ниже). Занимаемый таблицей объём памяти также увеличится.
Начнём с простой двухмерной таблицы и поиска по ней.
Простая таблица
Таблицей в программе будет являться двумерный массив, давайте создадим его для второго графика с таким же шагом, как на предыдущем скриншоте справа (приблизительно в три раза меньше точек, чем в изначальной таблице, т.е. понизим разрешение оцифровки):
int table[][2] = { {145, 35}, {156, 38}, {170, 41}, {186, 44}, {200, 47}, {212, 50}, {224, 53}, {233, 56}, {240, 59}, {246, 62}, {253, 65}, {259, 68}, {264, 71}, {268, 74}, {269, 77}, {276, 80}, {298, 83}, {327, 86}, {410, 89}, };
Для поиска по таблице достаточно просто перебирать все ячейки, начиная с первой. Если сигнал в следующей ячейке будет больше, чем текущий сигнал, считаем текущую ячейку искомой:
int getVal(int signal) { // поиск for (int i = 0; i < tableSize - 1; i++) { // если сигнал в следующей ячейке больше - // вернуть значение в текущей ячейке if (table[i + 1][0] > signal) return table[i][1]; } // вернуть последний, если вылетели за диапазон return table[tableSize - 1][1]; }
Результат работы функции для нашего диапазона сигналов: Вот такая получается ступенчатая конструкция, для уменьшения погрешности нужно брать больше точек, или... заменить ступеньки между точками линейным отрезком, вот так: Делается это очень просто, просто добавляем map()
между соседними точками:
int getVal(int signal) { if (signal < table[0][0]) return table[0][1]; // поиск for (int i = 0; i < tableSize - 1; i++) { // если сигнал в следующей ячейке больше - // вернуть значение по линейному отрезку между точками if (table[i + 1][0] > signal) return map(signal, table[i][0], table[i + 1][0], table[i][1], table[i + 1][1]); } // вернуть последний, если вылетели за диапазон return table[tableSize - 1][1]; }
Результат (примечание: ступеньки по вертикальной оси справа связаны с целочисленным вычислением значения, т.е. шаг ступеньки там - единица): Лучше? Лучше! Таким образом можно добиться максимальной близости к реальному графику при помощи минимального количества точек в таблице.
Прячем в PROGMEM
Для хранения массива в программной памяти достаточно добавить ключевое слово PROGMEM
при объявлении, а также сделать функцию или макрос, которая будет доставать данные из pgm:
const int tableSize = 19; const int table[][2] PROGMEM = { {145, 35}, {156, 38}, {170, 41}, {186, 44}, {200, 47}, {212, 50}, {224, 53}, {233, 56}, {240, 59}, {246, 62}, {253, 65}, {259, 68}, {264, 71}, {268, 74}, {269, 77}, {276, 80}, {298, 83}, {327, 86}, {410, 89}, }; void setup() { Serial.begin(9600); for (int i = 100; i < 430; i += 5) { Serial.print(i); Serial.print('\t'); Serial.println(getVal(i)); } } #define pgm_table(x, y) pgm_read_word(&table[(x)][(y)]) int getVal(int signal) { if (signal < pgm_table(0, 0)) return pgm_table(0, 1); // поиск for (int i = 0; i < tableSize - 1; i++) { // если сигнал в следующей ячейке больше - // вернуть значение в текущей ячейке if (pgm_table(i + 1, 0) > signal) return map(signal, pgm_table(i, 0), pgm_table(i + 1, 0), pgm_table(i, 1), pgm_table(i + 1, 1)); } // вернуть последний, если вылетели за диапазон return pgm_table(tableSize - 1, 1); } void loop() {}
Библиотека Approxy (NEW)
Показанный выше вариант с таблицей я обернул в библиотеку Approxy. Рассмотрим пример из неё с такими же исходными данными. Вариант с хранением в PROGMEM:
#include <Approxy.h> // двумерный массив, у столбцов одинаковый тип const int tab[][2] PROGMEM = { {145, 35}, {156, 38}, {170, 41}, {186, 44}, {200, 47}, {212, 50}, {224, 53}, {233, 56}, {240, 59}, {246, 62}, {253, 65}, {259, 68}, {264, 71}, {268, 74}, {269, 77}, {276, 80}, {298, 83}, {327, 86}, {410, 89}, }; Approxy2D<int, AP_PGM> table(tab, 19); void setup() { Serial.begin(9600); for (int i = 145; i < 410; i++) { Serial.println(table.get(i)); } } void loop() {}
Линейная таблица
Для создания линейной таблицы нужно производить измерения, строго придерживаясь одинакового шага по сигналу. В саму таблицу идут соответствующие значения. Пример выборки по всё тому же второму графику с шагом по сигналу 10:
140 35 150 36 160 39 170 41 180 43 190 45 200 47 210 49 220 51 230 55 240 59 250 63 260 68 270 78 280 80 290 82 300 83 310 84 320 85 330 86 340 86 350 87 360 87 370 88 380 88 390 88 400 88 410 89
На графике это выглядит так: одинаковый шаг по оси сигнала Что теперь: мы оставляем только столбец значений и пакуем их в одномерный массив. Также запоминаем минимальный и максимальный сигнал в таблице, с его помощью будем мапить:
int table[] = { 35, 36, 39, 41, 43, 45, 47, 49, 51, 55, 59, 63, 68, 78, 80, 82, 83, 84, 85, 86, 86, 87, 87, 88, 88, 88, 88, 89, }; const int minSignal = 140; const int maxSignal = 410; const int tableSize = 28; void setup() { Serial.begin(9600); for (int i = 100; i < 430; i += 5) { Serial.print(i); Serial.print('\t'); Serial.println(getVal(i)); } } int getVal(int signal) { // ограничиваем сигнал signal = constrain(signal, minSignal, maxSignal); // ищем номер ячейки int i = map(signal, minSignal, maxSignal, 0, tableSize - 1); // возвращаем значение return table[i]; } void loop() {}
И точно так же массив можно запрятать в прогмем:
int getVal(int signal) { // ограничиваем сигнал signal = constrain(signal, minSignal, maxSignal); // ищем номер ячейки int i = map(signal, minSignal, maxSignal, 0, tableSize - 1); // возвращаем значение return pgm_read_word(&table[i]); }
Линеаризовать переходы между точками в этом случае труднее, но тоже можно: зная шаг изменения сигнала (он у нас одинаковый) и его начальное значение, восстановим соответствующие точкам значения:
int table[] = { 35, 39, 43, 47, 51, 59, 68, 80, 83, 85, 86, 87, 88, 88, }; const int minSignal = 140; const int maxSignal = 400; const int stepSignal = 20; const int tableSize = 14; void setup() { Serial.begin(9600); for (int i = 100; i < 430; i += 5) { Serial.print(i); Serial.print('\t'); Serial.println(getVal(i)); } } int getVal(int signal) { // ограничиваем сигнал signal = constrain(signal, minSignal, maxSignal); // ищем номер ячейки // ещё -1 за счёт линеаризации! int i = map(signal, minSignal, maxSignal-stepSignal, 0, tableSize - 2); int thisMin = stepSignal * i + minSignal; int thisMax = thisMin + stepSignal; // возвращаем значение return map(signal, thisMin, thisMax, table[i], table[i + 1]); } void loop() {}
И вот так мы восстановили график значения от сигнала всего по 14 точкам в одномерном массиве, зная минимум, максимум и шаг изменения сигнала!
float map()?
Для линеаризации в предыдущих примерах мы использовали функцию map()
, которая возвращает целые числа. Что делать, если нужна более высокая точность? Можно работать в более мелкой шкале (например миллиметры вместо сантиметров), а можно сделать свой map, который будет считать во float
:
float map_f(long x, long in_min, long in_max, long out_min, long out_max) { return (float)(x - in_min) * (out_max - out_min) / (in_max - in_min) + out_min; }
И использовать его вместо обычного при расчёте значения между точками. Вот так будет выглядеть самый последний пример с дробной линеаризацией, уже без ступенек:
Что выбрать?
Плюсы, минусы и особенности всех способов мы уже разобрали выше. Какой выбрать для своей задачи? Если время вычисления не критично и график можно аппроксимировать функцией - однозначно лучше сделать так. Если график сложный и выдержать ровный шаг изменения сигнала при ручном тарировании сложно - делать двумерную таблицу с поиском. Если при ручном изменении есть возможность четко контролировать сигнал - есть смысл заморочиться и сделать одномерную таблицу, ведь с ней можно добиться вдвое большего разрешения оцифровки, чем с двумерной таблицей такого же "веса"!
Полезные страницы
- Набор GyverKIT – большой стартовый набор Arduino моей разработки, продаётся в России
- Каталог ссылок на дешёвые Ардуины, датчики, модули и прочие железки с AliExpress у проверенных продавцов
- Подборка библиотек для Arduino, самых интересных и полезных, официальных и не очень
- Полная документация по языку Ардуино, все встроенные функции и макросы, все доступные типы данных
- Сборник полезных алгоритмов для написания скетчей: структура кода, таймеры, фильтры, парсинг данных
- Видео уроки по программированию Arduino с канала “Заметки Ардуинщика” – одни из самых подробных в рунете
- Поддержать автора за работу над уроками
- Обратная связь – сообщить об ошибке в уроке или предложить дополнение по тексту ([email protected])