Исследование аналоговых сигналов гораздо более интересно, чем расшифровка цифровых, и в этом уроке мы поговорим о такой ситуации, когда нужно оцифровать и "запомнить" в микроконтроллере аналоговый сигнал. Как и зачем это может быть нужно? Чаще всего это встречается в случаях, когда некий датчик выдаёт сигнал в зависимости от каких-то внешних условий, например термистор меняет сопротивление в зависимости от температуры, потенциометр меняет сопротивление в зависимости от угла поворота, фоторезистор меняет сопротивление в зависимости от освещённости, а датчик холла выдаёт напряжение в зависимости от интенсивности магнитного поля. Как найти измеряемую физическую величину, зная "сырой" сигнал с датчика? Правильно, нужно сообщить микроконтроллеру, какой величине какой сигнал соответствует. Такой процесс называется тарированием. В целом существует два подхода:
- Аппроксимировать соответствие между сигналом и величиной при помощи функции, то есть величина
v
станет функциейf
от сигналаs
:v = f(s)
. Функция может быть какой угодно: линейная зависимость, квадратичная, экспоненциальная, логарифмическая и даже их смеси в виде кусочных функций. Например, зависимость между температурой и сопротивлением термистора описывается уравнением Стейнхарта-Харта, что позволяет определить температуру любого термистора, зная его характеристики и сопротивление ("сигнал")- Плюсы: получение значения вне исследованного диапазона, так как поведение графика "предсказывается" аппроксимирующей функцией. Малый размер в памяти, фактически пара строк кода с вычислениями
- Минусы: вычисления, особенно float, логарифмы и степенные функции, занимают значительное время
- Создать таблицу (массив) значений сигнала и соответствующей ему величины. Проблема в том, что аналоговый сигнал непрерывен, то есть имеет условно бесконечно большое разрешение: можно разбить график на бесконечное количество точек и каждой будет соответствовать разное значение! А память микроконтроллера у нас не резиновая. Так что придётся ограничиться конечным разрешением оцифровки, либо использовать более хитрые трюки, о которых мы поговорим ниже.
- Плюсы: оцифровка графика любой формы, высокое соответствие с реальным значением, максимально быстрое получение значения
- Минусы: таблица занимает много места в памяти, разрешение конечное
Поводом для создания этого урока послужил пост у нас в сообществе, где человек просил помочь с тарированием вот этих двух графиков (получены с условного аналогового датчика расстояния путём ручного измерения сигнала в нескольких точках):
Эти графики идеально подходят для разбора данной темы, поэтому попробуем создать их цифровые модели обоими способами. Ниже прилагаю исходный набор точек графиков для тех, кто захочет поиграться с ними самостоятельно. Первый столбец - сигнал (ось x), второй - значение (ось y).
График на первой картинке отображён со сменой осей. Далее в уроке мы используем горизонтальную ось сигнала, а вертикальную - значения
График 1
405 10
385 15
375 20
363 25
350 30
338 35
323 40
309 45
297 50
288 55
График 2
145 35
148 36
151 37
156 38
160 39
165 40
170 41
175 42
180 43
186 44
190 45
196 46
200 47
205 48
209 49
212 50
217 51
221 52
224 53
227 54
230 55
233 56
235 57
237 58
240 59
242 60
245 61
246 62
249 63
251 64
253 65
255 66
257 67
259 68
261 69
263 70
264 71
266 72
267 73
268 74
269 75
269 76
269 77
270 78
272 79
276 80
281 81
289 82
298 83
303 84
315 85
327 86
343 87
364 88
410 89
Аппроксимация функцией #
Первый график очень близок к линейной зависимости, поэтому можно аппроксимировать его прямой линией от первой точки до последней, то есть вот так:
Эта задача решается максимально просто: в Arduino у нас есть замечательная функция 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);
}
Вот таким образом можно решить задачу даже для неприятного на вид графика.
Таблица соответствий #
Таблица - сохранённые в памяти точки графика, позволяющие найти значение по соответствующему сигналу, в англоязычной литературе такой подход называют lookup table. С таблицей есть два варианта:
- Двухмерная таблица. Один столбец хранит сигнал, второй - соответствующее ему значение. Для сложного графика можно оптимизировать размер таблицы, сделав меньше точек на прямых участках, и больше - на участках сложной формы. Заполняется такая таблица последовательно с любым шагом, потому что в дальнейшем мы будем делать поиск по таблице. Чем больше таблица, тем дольше будет происходить поиск дальних значений (речь идёт о единицах микросекунд, не более).
- Одномерная таблица. Такая таблица заполняется с равным шагом по оси сигнала, а находятся в ней значения. Доступ к таблице осуществляется через преобразование сигнала к номеру ячейки, что позволяет получить одинаковое и минимальное время поиска для таблицы любого размера (фактически это не поиск, а выбор конкретной ячейки). Такой тип таблицы использует в два раза меньше памяти, чем двумерная таблица, а также имеет максимальную скорость доступа. Для критичных ко времени преобразований следует отдать предпочтение этому способу, ведь он будет даже быстрее, чем аппроксимация функцией.
Метод таблиц позволяет более точно и близко к реальному графику найти "значение" по "сигналу", и тут есть интересные моменты:
- Таблица, очевидно, будет занимать гораздо больше места в памяти МК. Таблицу можно и нужно разместить в 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 #
Показанный выше вариант с таблицей я обернул в библиотеку 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;
}
И использовать его вместо обычного при расчёте значения между точками. Вот так будет выглядеть самый последний пример с дробной линеаризацией, уже без ступенек:
Что выбрать? #
Плюсы, минусы и особенности всех способов мы уже разобрали выше. Какой выбрать для своей задачи? Если время вычисления не критично и график можно аппроксимировать функцией - однозначно лучше сделать так. Если график сложный и выдержать ровный шаг изменения сигнала при ручном тарировании сложно - делать двумерную таблицу с поиском. Если при ручном изменении есть возможность четко контролировать сигнал - есть смысл заморочиться и сделать одномерную таблицу, ведь с ней можно добиться вдвое большего разрешения оцифровки, чем с двумерной таблицей такого же "веса"!