http://habrahabr.ru/post/242377/
Привет, Хабр!
Я учусь в МФТИ и занимаюсь научной работой в Институте общей физики РАН. Профиль нашей лаборатории — лазерное дистанционное зондирование, конкретно — лидары. Если вы не знаете, что это за звери, можно прочесть, к примеру,
в википедии. В двух словах, лидар — это радар, в котором вместо радиоволны используется лазерное излучение. Принципиальное отличие и преимущество лидара в том, что с его помощью можно судить не только о расстоянии до объекта зондирования по задержке обратного сигнала, но и (по спектру этого сигнала) о составе и свойствах объекта. К примеру, существуют методы лидарного определения температурного профиля воды в водоёмах в зависимости от глубины.
Бесконтактные измерения — очень заманчиво, но они полезны лишь настолько, насколько точны, поэтому для калибровки результатов дистанционных измерений непосредственными было решено сделать термокосу — шлейф из нескольких термодатчиков на одной линии.
Железо
Бесконтактный метод с применением лидара позволяет промерять температуру воды на глубину до нескольких метров (это зависит от прозрачности, ясно, что в грязной воде лазерный пучок быстро рассеивается и далеко не проходит), поэтому термокоса небольшая, состоит из пяти термодатчиков, размещённых на кабеле с интервалом 1 м, плюс ещё 4 м кабеля, считая от «верхнего» датчика.
В качестве чувствительных элементов я выбрал цифровые термометры DS18B20 (
даташит, 320 кб) в герметичном исполнении, вот такие:
Почему именно такие? Потому что герметичные (улыбка), поставляются уже с кабелем длиной 1 м, дают высокую точность и работают по протоколу
1-Wire, что существенно упрощает коммуникацию с ними.
Вдумчивое изучение даташита дало следующую информацию. Датчики можно подключать двумя способами: обычным, по трём проводам (земля, «плюс» питания и сигнальная шина) и в «паразитном» режиме, когда питание датчик получает с линии данных. «Паразитный» режим ещё более упрощает подключение (всего два провода), но может иногда искажать показания датчика. Любое ухудшение точности нам вредно, да и с платы Arduino, управляющей датчиками, легко доступны 5 вольт, поэтому я решил запитывать датчики обычным способом.
Схема термокосы
Даташит рекомендует использовать подтягивающий резистор номиналом 4.7 кОм, у меня в хозяйстве нашлись только два по 2.2, но на работоспособности прибора это не сказалось.
За управление датчиками и за их связь с внешним миром, т.е., с ПК, отвечает Arduino Nano с контроллером ATMega328P.
Вот так выглядит схема, собранная на макетной плате:
Вот так — конечный вариант после пайки и изолирования:
А это — вся термокоса в сборе (управляющая электроника не изолирована):
Я выбрал Arduino в качестве «мозгов» устройства, во-первых, потому, что эта платформа проста в освоении, а во-вторых, потому что ею можно управлять с ПК из-под LabVIEW (далее для краткости LabVIEW = LV), что немаловажно, так как софт большинства проектов нашей лаборатории написан именно в этой среде, и возможность встраивания простой автоматизированной системы контроля температуры в другие схемы дорого стоит.
Софт
Главной фишкой данной задачи является работа с прибором из среды LV, поэтому программирование было решено начать с изучения взаимодействия Arduino и LV. На хабре практически нет информации об этом взаимодействии, поэтому, с вашего позволения, буду описывать всё достаточно подробно.
Начало
Итак, что нам нужно (информация
отсюда):
- LV 2009 или новее.
- NI VISA (модуль LV для общения виртуальных приборов с реальными).
- Arduino IDE и драйвера.
- Библиотека OneWire для Arduino — положите содержимое ZIPа в /[директория установки Arduino IDE]/libraries/.
- Разработчик LV предлагает расширение для работы с платами Arduino — LabVIEW Interface for Arduino, или просто LIFA. С недавнего времени развитие LIFA официально прекращено, вместо него NI предлагают пользоваться тулкитом LINX от LabVIEW Hacker. Он поддерживает бóльшее число устройств и содержит больше инструментов, однако, я пользовался LIFA, потому что в LINX прошивки контроллеров имеют вид HEX-файлов, возиться с разборкой и редактированием которых у меня не было ни желания, ни времени. А в LIFA исходники — привычные для Arduino скетчи.
LIFA можно установить непосредственно из LV через интерфейс VI Package Manager (Tools -> VI Package Manager). После установки на палитре функций появится подпалитра «Arduino»:
Чтобы начать работать с Arduino в LV, нужно прошить ваш контроллер скетчем
LIFA_Base.ino, взятым из папки C:/Program Files/National Instruments/LabVIEW
[версия]/vi.lib/LabVIEW Interface for Arduino/Firmware/LIFA_Base/. В указанной папке лежит куча файлов — С-шные библиотеки, исходники и два скетча,
LabVIEWInterface.ino и
LIFA_Base.ino. Первый содержит описания всех функций для работы с Arduino, второй же коротенький и собирает всё воедино для заливки в контроллер.
Вуаля, теперь мы с компьютера посредством LV имеем доступ к большинству возможностей Arduino. Как вы догадываетесь, первое, что я сделал, разобравшись со всем описанным выше, — это поморгал светодиодиком на пине №13 (улыбка).
Поигрались, теперь за дело.
Протокол 1-Wire и термодатчики DS18B20 существуют уже достаточно давно и широко распространены, поэтому я решил поискать информацию о совместном использовании DS18B20 и Arduino. И почти сразу же наткнулся на подходящий источник, и не где-нибудь, а на официальном форуме LabVIEW (
ссылка). Топикстартер имел сходную с моей задачу — считывать показания термодатчика DS18B20 с Arduino из среды LabVIEW. Он занялся поисками и в
ролике на YouTube увидел диаграмму LV с присутствующим на ней ВП OneWire Read и спросил у гуру, что это за ВП и где его заиметь. На его просьбу откликнулся автор видео и предоставил исходники и подробную инструкцию, как и что делать.
Датчики DS18B20 управляются следующим образом: «мастер» (контроллер, микропроцессор) посылает по линии данных двузначную шестнадцатеричную команду, в зависимости от которой датчик производит измерение температуры, принимает от «мастера» байты на запись в свою память либо отправляет на линию данных текущее содержимое памяти. Автор видео модифицировал скетчи, заливаемые в Arduino для работы с LIFA:
- В файле LIFA_Base.ino подключил библиотеку OneWire.h,
- В файле LabVIEWInterface.ino в структуре case, отвечающей за обработку команд, поступающих из LV по последовательной шине, он добавил вариант 0x1E, вызывающий функцию считывания температуры, написанную им же:
Кодcase 0x1E: // OneWire Read
OneWire_Read()
break;
Функция эта отправляет на линию данных команду измерения температуры 0x44 («конвертирование»), дожидается окончания конвертирования, отправляет команду считывания памяти 0xBE, читает, из полученной информации достаёт показание температуры и отправляет его на последовательную шину:
Кодvoid OneWire_Read()
{
OneWire ds(2); // Create a OneWire Object "ds" on pin 2. Hard coding for now, because I can't declare this in a case.
byte OneWireData[9]; // Defining stuff for the added OneWire function because I'm getting irritated with trying to make this fit into a case or function.
int Fract, Whole, Tc_100, SignBit, TReading;
// Start the Conversion
ds.reset(); // Reset the OneWire bus in preparation for communication
ds.skip(); // Skip addressing, since there is only one sensor
ds.write(0x44); // Send 44, the conversion command
// Wait for the Conversion
delay(1000); // Wait for the conversion to complete
// Read back the data
ds.reset(); // Reset the OneWire bus in preparation for communication
ds.skip(); // Skip addressing, since there is only one sensor
ds.write(0xBE); // Send the "Read Scratchpad" command
for ( byte i = 0; i < 9; i++) {
OneWireData[i] = ds.read(); // Read the 9 bytes into data[]
}
// Scale the data
TReading = (OneWireData[1] << 8) + OneWireData[0];
SignBit = TReading & 0x8000; // Mask out all but the MSB
if (SignBit) // If the MSB is negative, take the Two's Compliment to make the reading negative
{
TReading = (TReading ^ 0xffff) + 1; // 2's comp
}
Tc_100 = (6 * TReading) + TReading / 4; // Scale by the sensitivity (0.0625°C per bit) and 100
Whole = Tc_100 / 100; // Split out the whole number portion of the reading
Fract = Tc_100 % 100; // Split out the fractional portion of the reading
// Return the data serially
if (SignBit) { // If the reading is negative, print a negative sign
Serial.print("-");
}
Serial.print(Whole); // Print the whole number portion and a decimal
Serial.print(".");
if (Fract < 10) { // if the fraction portion is less than .1, append a 0 decimal
Serial.print("0");
}
Serial.print(Fract); // Otherwise print the fractional portion
}
Предложенный же ВП, в сущности, всего лишь отправляет в указанный ему порт последовательного интерфейса шестнадцатеричное число 1E, дожидается ответа и считывает его:
Всё довольно просто.
Читаем один датчик вручную
Первым делом я отредактировал
LIFA_BASE.ino и
LabVIEWInterface.ino в соответствии с инструкциями и сделал ВП. Проверил, всё работает, отлично. Потом я сделал кое-что, о чём впоследствии пожалел. В вышеуказанной теме на форуме LV парой сообщений ниже один из участников предложил свою версию ВП, считывающего показания термодатчика, состоящую, по сути, всего из одного подприбора — Send Receive.vi из подпалитры Arduino:
Соблазнившись простотой и не вникнув в подробности, в своих дальнейших экспериментах я ничтоже сумняшеся пользовался этой простенькой версией. Нет-нет, всё хорошо и прекрасно, она корректно работает, однако, тут есть некая тонкость, связанная с различиями между моим сценарием работы цепочки датчик-Arduino-LabVIEW и тем сценарием, для которого сделан ВП с форума. Эта тонкость доставила мне впоследствии некоторое количество головной боли, но об этом чуть позже.
Одной из особенностей датчиков DS18B20 является то, что каждый отдельный экземпляр имеет свой уникальный 8-байтовый адрес (ROM-код), зашитый в него в процессе производства. Это теоретически позволяет вешать на одну 1-Wire линию неограниченно много датчиков. Для реализации такой возможности предусмотрена команда адресации к конкретному датчику.
Чтобы адресоваться, нужно знать адрес (улыбка). ROM-коды своих датчиков я узнал, воспользовавшись примером
DS18x20_Temperature из библиотеки OneWire, и записал их в пять переменных, объявленных в начале программы:
// DS18B20 temperature sensors' addresses:
byte sensor_1[8] = {0x28,0xFF,0xBE,0xCE,0x14,0x14,0x00,0x8A};
byte sensor_2[8] = {0x28,0xFF,0x42,0x43,0x15,0x14,0x00,0xE2};
byte sensor_3[8] = {0x28,0xFF,0xED,0x55,0x15,0x14,0x00,0x8F};
byte sensor_4[8] = {0x28,0xFF,0x3D,0x6E,0x15,0x14,0x00,0x0D};
byte sensor_5[8] = {0x28,0xFF,0x5E,0x66,0x15,0x14,0x00,0x4E};
В предложенном варианте OneWire_Read не получает никаких значений. Добавляем в неё параметр — адрес датчика (байтовый массив из 8 элементов):
void OneWire_Read(byte addr[8])
Перед каждой отправкой какой-либо команды адресуемся к датчику:
// Start the Conversion
ds.reset(); // Reset the OneWire bus in preparation for communication
ds.select(addr); // Addressing
ds.write(0x44); // Send 44, the conversion command
// Read back the data
ds.reset(); // Reset the OneWire bus in preparation for communication
ds.select(addr); // Addressing
ds.write(0xBE); // Send the "Read Scratchpad" command
и добавляем по варианту на каждый датчик в структуру выбора:
/*********************************************************************************
** OneWire temperature sensors reading
*********************************************************************************/
case 0x2E: // sensor 1 read
OneWire_Read(sensor_1);
break;
case 0x2F: // sensor 2 read
OneWire_Read(sensor_2);
break;
case 0x30: // sensor 3 read
OneWire_Read(sensor_3);
break;
case 0x31: // sensor 4 read
OneWire_Read(sensor_4);
break;
case 0x32: // sensor 5 read
OneWire_Read(sensor_5);
break;
Для испытаний того, что получилось, я сделал свой маленький ВП для единичного опроса одного датчика:
Как видно, выбор датчика для опроса я реализовал через case-структуру на блок-диаграмме.
Для удобства дальнейшего применения я сваял маленький ВПП, как показано на скриншоте ниже,
запарился и нарисовал для него няшную иконку и обозвал DS18B20 Read.
Не считая кластеров ресурса Arduino и ошибок, ВПП получает на вход номер датчика для опроса и на выход подаёт показание температуры в виде строки.
Ура! Испытания прошли успешно.
Читаем один датчик в автоматическом режиме
Хорошо, мы теперь умеем опрашивать вручную один датчик. Следующий шаг — циклический опрос одного датчика в автоматическом режиме. Для этого я сделал следующую блок-диаграмму:
Для начала интервал фиксирован, программа раз в секунду опрашивает датчик и после остановки цикла пользователем пишет собранные данные в массив. Для удобства я к каждому показанию температуры добавил временну́ю метку с помощью функции Get Date/Time String.
Включаем, ждём секунд 20, останавливаем… И тут начинается веселье.
Просмотр массива показывает, что температура считывается только первые 5 раз после запуска программы, дальше лишь временны́е метки без показаний температуры:
Я долго не мог понять, в чём же дело — на стороне LV вроде бы ошибки быть не может, блок-диаграмма до безобразия проста, код скетча Arduino тоже корректен, т.к. в режиме единичного ручного опроса работает безотказно. Что ещё может быть? Сама плата Arduino? Понаблюдав за ней, обнаружил следующее. Запускаем программу, дважды мигает светодиод L на пине 13, потом мигает светодиод RX (контроллер принял команду для термодатчика, отправленную ПК), проходит одна секунда (датчик проводит «конвертирование» температуры в байты в своей памяти, ПК ждёт от него ответа), мигает светодиод TX (контроллер получил от датчика байты и отправил их ПК), снова мигает диод RX, снова проходит секунда, снова мигает TX, и так далее по кругу, пока мы не остановим выполнение программы. Так вот, в моей схеме этот калейдоскоп огоньков продолжался первые ~5 секунд, а потом контроллер переставал отвечать, беспрерывно мигал диод RX, и программу получалось остановить только кнопкой останова выполнения в интерфейсе LabVIEW.
Вся эта катавасия натолкнула меня на мысль, что где-то что-то не в порядке с таймингом, и я начал копать в этом направлении, изменял время ожидания в ВП, в скетче, анализировал код скетча буквально по строчке, блок-диаграмму ВП по элементику, но ничего не помогало. В конце концов
от отчаяния распотрошил Send Receive.vi, потому что ну неоткуда больше было взяться проблеме. Взгляните на его блок-диаграмму:
Send Receive, как ему и полагается, берёт данные, отправляет их по указанному направлению и принимается ждать. Если в течение 100 миллисекунд ответа не поступает, ждёт ещё 5 миллисекунд, очищает буфер вывода и повторно отправляет данные, всего до 10 таких попыток. Где-то между Send Receive, микроконтроллером и главным ВП в процессе работы возникает и накапливается рассинхрон, и из-за этого к шестой итерации опроса датчика происходит какая-то нестыковка отправляемых и принимаемых команд, которая вешает контроллер.
Как показывает опыт, простое на вид решение — не всегда самое лучшее, поэтому я переделал свой DS18B20 Read.vi:
Признаюсь честно, я не могу точно сказать, в чём же было дело, не хватает глубины понимания взаимодействия микроконтроллера с ПК. Но в результате моих попыток проблема исчезла, и я не стал в неё углубляться.
Читаем все датчики в автоматическом режиме
Умея читать в авторежиме один датчик, запилить чтение сразу всех пяти — дело техники. Для этого я вписал в
LabVIEWInterface.ino ещё одну функцию — OneWire_Read_All():
Кодvoid OneWire_Read_All()
{
OneWire ds(2);
byte Data[9];
int Fract, Whole, Tc_100, SignBit, TReading;
ds.reset();
ds.skip(); // Addressing to all sensors on the line
ds.write(0x44);
delay(1000);
// reading sensor 1
ds.reset();
ds.select(sensor_1); // Addressing to sensor 1
ds.write(0xBE);
for ( byte i = 0; i < 9; i++)
{
Data[i] = ds.read();
}
TReading = (Data[1] << 8) + Data[0];
SignBit = TReading & 0x8000;
if (SignBit)
{
TReading = (TReading ^ 0xffff) + 1;
}
Tc_100 = (6 * TReading) + TReading / 4;
Whole = Tc_100 / 100;
Fract = Tc_100 % 100;
if (SignBit)
{
Serial.print("-");
}
Serial.print(Whole);
Serial.print(",");
if (Fract < 10)
{
Serial.print("0");
}
Serial.print(Fract);
Serial.print(" ");
// reading sensor 2
ds.reset();
ds.select(sensor_2); // Addressing to sensor 2
ds.write(0xBE);
for ( byte i = 0; i < 9; i++)
{
Data[i] = ds.read();
}
TReading = (Data[1] << 8) + Data[0];
SignBit = TReading & 0x8000;
if (SignBit)
{
TReading = (TReading ^ 0xffff) + 1;
}
Tc_100 = (6 * TReading) + TReading / 4;
Whole = Tc_100 / 100;
Fract = Tc_100 % 100;
if (SignBit)
{
Serial.print("-");
}
Serial.print(Whole);
Serial.print(",");
if (Fract < 10)
{
Serial.print("0");
}
Serial.print(Fract);
Serial.print(" ");
// reading sensor 3
ds.reset();
ds.select(sensor_3); // Addressing to sensor 3
ds.write(0xBE);
for ( byte i = 0; i < 9; i++)
{
Data[i] = ds.read();
}
TReading = (Data[1] << 8) + Data[0];
SignBit = TReading & 0x8000;
if (SignBit)
{
TReading = (TReading ^ 0xffff) + 1;
}
Tc_100 = (6 * TReading) + TReading / 4;
Whole = Tc_100 / 100;
Fract = Tc_100 % 100;
if (SignBit)
{
Serial.print("-");
}
Serial.print(Whole);
Serial.print(",");
if (Fract < 10)
{
Serial.print("0");
}
Serial.print(Fract);
Serial.print(" ");
// reading sensor 4
ds.reset();
ds.select(sensor_4); // Addressing to sensor 4
ds.write(0xBE);
for ( byte i = 0; i < 9; i++)
{
Data[i] = ds.read();
}
TReading = (Data[1] << 8) + Data[0];
SignBit = TReading & 0x8000;
if (SignBit)
{
TReading = (TReading ^ 0xffff) + 1;
}
Tc_100 = (6 * TReading) + TReading / 4;
Whole = Tc_100 / 100;
Fract = Tc_100 % 100;
if (SignBit)
{
Serial.print("-");
}
Serial.print(Whole);
Serial.print(",");
if (Fract < 10)
{
Serial.print("0");
}
Serial.print(Fract);
Serial.print(" ");
// reading sensor 5
ds.reset();
ds.select(sensor_5); // Addressing to sensor 5
ds.write(0xBE);
for ( byte i = 0; i < 9; i++)
{
Data[i] = ds.read();
}
TReading = (Data[1] << 8) + Data[0];
SignBit = TReading & 0x8000;
if (SignBit)
{
TReading = (TReading ^ 0xffff) + 1;
}
Tc_100 = (6 * TReading) + TReading / 4;
Whole = Tc_100 / 100;
Fract = Tc_100 % 100;
if (SignBit)
{
Serial.print("-");
}
Serial.print(Whole);
Serial.print(",");
if (Fract < 10)
{
Serial.print("0");
}
Serial.print(Fract);
}
Как видите, она, за небольшими изменениями, является повторённой 5 раз функцией чтения одного датчика.
Также пришлось немного изменить DS18B20 Read.vi — сделал его универсальным, как для опроса отдельных датчиков (получает на вход номер от 1 до 5), так и для всех сразу (6 на входе). Ещё я изменил число байтов, читаемых из буфера, т.к. при опросе всех датчиков сразу на выходе ВП строка почти в 6 раз длиннее, и увеличил интервал опроса буфера:
Ура, товарищи! Всё работает именно так, как я хотел.
Калибровка
Казалось бы, всё готово, тут можно и успокоиться, но при тестах все пять датчиков, будучи помещёнными в одинаковые условия (стакан с водой), давали несколько разные показания. Поэтому их нужно было прокалибровать.
Для этого понадобились: ртутный термометр с ценой деления 0,01 градус Цельсия, лабораторная стойка с лапкой (возможно, знакомая вам по школьным лабораторным работам по физике (улыбка)), стакан, немного льда из морозилки, электрочайник и вода. Импровизированная установка выглядела так:
Прошу прощения за качество фотографий и за беспорядок в лаборатории.
Для нескольких температур были записаны показания ртутного термометра и датчиков, и построены калибровочные кривые для каждого датчика.
В качестве примера — калибровочная кривая для датчика №1.
По параметрам полученных кривых я внёс калибровочные поправки в данные, выдаваемые программой.
Также с помощью этой же «установки» по сравнению показаний датчиков и ртутного термометра была оценена погрешность, даваемая термокосой. Для разных датчиков при разных температурах она незначительно отличается и в среднем составляет 0,08 градусов Цельсия.
Последние штрихи
Интерфейс LIFA для работы с Arduino предоставляет кучу возможностей — работа с LCD-дисплеями, серводвигателями, управление по ИК-каналу и т.д., это всё полезно, но в моём случае совершенно не нужно, и поэтому я довольно радикально урезал содержимое
LabVIEWInterface.ino,
LIFA_BASE.ino,
LabVIEWInterface.h и папки LIFA_Base, убрав оттуда всё лишнее. Листинги тут приводить не буду, если кому-нибудь захочется поглядеть, обращайтесь, все исходники предоставлю с удовольствием.
Для управляющей программы я сделал вот такую фронт-панель:
Платка Arduino для защиты от окружающей среды была упакована в термоусадочную трубку, загерметизированную с торцов:
Прибор готов:
Итоги
Стоимость компонентов и материалов:
- Arduino Nano — 1900 руб;
- 5 термодатчиков DS18B20 — 1950 руб;
- 10 м кабеля — 150 руб;
- Мелочи (термоусадка, кабельные стяжки, ...) — 200 руб;
В сумме — 4200 руб.
А теперь давайте подумаем. В продаже есть фабричные термокосы, легко гуглится, к примеру, «термокоса ТК-10/10» средней стоимостью 13000 рублей. Вы можете спросить: «А нафига было париться, если существуют аналоги промышленного изготовления сравнимой стоимости, дающие такую же или пренебрежимо худшую точность, заведомо лучше отлаженные, более надёжные и качественно исполненные?» Отвечу, тому несколько причин:
- /*Говоря не о серьёзной научной аппаратуре, а об устройствах, подобных описываемому выше.*/ Покупая готовое решение, ты вынужден верить цифрам характеристик, которые указал производитель. Это нормально при применении прибора на производстве или в быту, но не для научных целей. Я не говорю, что производитель намеренно даёт ложные сведения, но, как правило, ты ничего не знаешь о тонкостях внутреннего устройства, о методиках оценки параметров прибора, использованных при его изготовлении, а они могут оказаться неточными или содержать неуместные допущения. В общем, вы поняли, главный принцип научного мировоззрения — «Ничего не принимай на веру». Другое дело, если собираешь прибор сам буквально по детальке, сам задаёшь логику его работы и оцениваешь его точность по выбранным тобой методам.
- С образовательной точки зрения изготовление термокосы принесло ценный опыт работы паяльником, программирования Arduino и понимания его связи с компьютером посредством LabVIEW, особенно в свете того, что я продолжаю изучение связки Arduino-LV-ПК в проекте, на который переключился по окончании этого.
- В меньшей степени, но вопрос стоимости тоже имел значение.
Благодарю всех за внимание! Если возникнут вопросы/предложения/критика, всегда рад выслушать, исходники скетчей и VI-шки предоставлю с удовольствием, как уже писал выше, обращайтесь.
P.S. Мои навыки в программировании недалеко ушли от «Hello world!», поэтому не судите строго, если какие-то термины я употребил неточно или не совсем по назначению.