[Из песочницы] Термокоса под управлением Arduino и LabVIEW
Привет, Хабр! Я учусь в МФТИ и занимаюсь научной работой в Институте общей физики РАН. Профиль нашей лаборатории — лазерное дистанционное зондирование, конкретно — лидары. Если вы не знаете, что это за звери, можно прочесть, к примеру, в википедии. В двух словах, лидар — это радар, в котором вместо радиоволны используется лазерное излучение. Принципиальное отличие и преимущество лидара в том, что с его помощью можно судить не только о расстоянии до объекта зондирования по задержке обратного сигнала, но и (по спектру этого сигнала) о составе и свойствах объекта. К примеру, существуют методы лидарного определения температурного профиля воды в водоёмах в зависимости от глубины.
Бесконтактные измерения — очень заманчиво, но они полезны лишь настолько, насколько точны, поэтому для калибровки результатов дистанционных измерений непосредственными было решено сделать термокосу — шлейф из нескольких термодатчиков на одной линии.
ЖелезоБесконтактный метод с применением лидара позволяет промерять температуру воды на глубину до нескольких метров (это зависит от прозрачности, ясно, что в грязной воде лазерный пучок быстро рассеивается и далеко не проходит), поэтому термокоса небольшая, состоит из пяти термодатчиков, размещённых на кабеле с интервалом 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 по последовательной шине, он добавил вариант 0×1E, вызывающий функцию считывания температуры, написанную им же: Код case 0×1E: // OneWire Read OneWire_Read () break; Функция эта отправляет на линию данных команду измерения температуры 0×44 («конвертирование»), дожидается окончания конвертирования, отправляет команду считывания памяти 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 (0×44); // 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-коды своих датчиков я узнал, воспользовавшись примером DS18×20_Temperature из библиотеки OneWire, и записал их в пять переменных, объявленных в начале программы:
// DS18B20 temperature sensors' addresses:
byte sensor_1[8] = {0×28,0xFF,0xBE,0xCE,0×14,0×14,0×00,0×8A}; byte sensor_2[8] = {0×28,0xFF,0×42,0×43,0×15,0×14,0×00,0xE2}; byte sensor_3[8] = {0×28,0xFF,0xED,0×55,0×15,0×14,0×00,0×8F}; byte sensor_4[8] = {0×28,0xFF,0×3D,0×6E,0×15,0×14,0×00,0×0D}; byte sensor_5[8] = {0×28,0xFF,0×5E,0×66,0×15,0×14,0×00,0×4E}; В предложенном варианте 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 (0×44); // 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 0×2E: // sensor 1 read OneWire_Read (sensor_1); break; case 0×2F: // sensor 2 read OneWire_Read (sensor_2); break; case 0×30: // sensor 3 read OneWire_Read (sensor_3); break; case 0×31: // sensor 4 read OneWire_Read (sensor_4); break; case 0×32: // 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 (0×44);
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!», поэтому не судите строго, если какие-то термины я употребил неточно или не совсем по назначению.