Оцифровка сигнала и тарирование


Исследование аналоговых сигналов гораздо более интересно, чем расшифровка цифровых, и в этом уроке мы поговорим о такой ситуации, когда нужно оцифровать и “запомнить” в микроконтроллере аналоговый сигнал. Как и зачем это может быть нужно? Чаще всего это встречается в случаях, когда некий датчик выдаёт сигнал в зависимости от каких-то внешних условий, например термистор меняет сопротивление в зависимости от температуры, потенциометр меняет сопротивление в зависимости от угла поворота, фоторезистор меняет сопротивление в зависимости от освещённости, а датчик холла выдаёт напряжение в зависимости от интенсивности магнитного поля. Как найти измеряемую физическую величину, зная “сырой” сигнал с датчика? Правильно, нужно сообщить микроконтроллеру, какой величине какой сигнал соответствует. Такой процесс называется тарированием. В целом существует два подхода:

  • Аппроксимировать соответствие между сигналом и величиной при помощи функции, то есть величина v станет функцией f от сигнала s: v = f(s). Функция может быть какой угодно: линейная зависимость, квадратичная, экспоненциальная, логарифмическая и даже их смеси в виде кусочных функций. Например, зависимость между температурой и сопротивлением термистора описывается уравнением Стейнхарта-Харта, что позволяет определить температуру любого термистора, зная его характеристики и сопротивление (“сигнал”).
    • Плюсы: получение значения вне исследованного диапазона, так как поведение графика “предсказывается” аппроксимирующей функцией. Малый размер в памяти, фактически пара строк кода с вычислениями. 
    • Минусы: вычисления, особенно float, логарифмы и степенные функции, занимают значительное время (десятки микросекунд).
  • Создать таблицу (массив) значений сигнала и соответствующей ему величины. Проблема в том, что аналоговый сигнал непрерывен, то есть имеет условно бесконечно большое разрешение: можно разбить график на бесконечное количество точек, и каждой будет соответствовать разное значение! А память микроконтроллера у нас не резиновая =) Так что придётся ограничиться конечным разрешением оцифровки, либо использовать более хитрые трюки, о которых мы поговорим ниже.
    • Плюсы: оцифровка графика любой формы, высокое соответствие с реальным значением, максимально быстрое получение значения (отсутствуют вычисления).
    • Минусы: таблица занимает много места в памяти, разрешение конечное.

Поводом для создания этого урока послужил пост у нас в сообществе, где человек просил помочь с тарированием вот этих двух графиков (получены с условного аналогового датчика расстояния путём ручного измерения сигнала в нескольких точках):

Эти графики идеально подходят для разбора данной темы, поэтому попробуем создать их цифровые модели обоими способами. Ниже прилагаю исходный набор точек графиков для тех, кто захочет поиграться с ними самостоятельно. Первый столбец – сигнал (ось x), второй – значение (ось y). Внимание! График на первой картинке отображён со сменой осей. Далее в уроке мы используем горизонтальную ось сигнала, а вертикальную – значения.

405	10
385	15
375	20
363	25
350	30
338	35
323	40
309	45
297	50
288	55

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(), которая позволяет перевести один диапазон значений в другой, и делает это как раз линейно. Напомню, что функция 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() {}

Линейная таблица


Для создания линейной таблицы нужно производить измерения, строго придерживаясь одинакового шага по сигналу. В саму таблицу идут соответствующие значения. Пример выборки по всё тому же второму графику с шагом по сигналу 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;
}

И использовать его вместо обычного при расчёте значения между точками. Вот так будет выглядеть самый последний пример с дробной линеаризацией, уже без ступенек:

Что выбрать?


Плюсы, минусы и особенности всех способов мы уже разобрали выше. Какой выбрать для своей задачи? Если время вычисления не критично и график можно аппроксимировать функцией – однозначно лучше сделать так. Если график сложный и выдержать ровный шаг изменения сигнала при ручном тарировании сложно – делать двумерную таблицу с поиском. Если при ручном изменении есть возможность четко контролировать сигнал – есть смысл заморочиться и сделать одномерную таблицу, ведь с ней можно добиться вдвое большего разрешения оцифровки, чем с двумерной таблицей такого же “веса”!

Важные страницы