Я так давно планировал эту статью…
Вы даже не представляете, как долго я ждал, чтобы взять сесть за компьютер, чтобы написать программу, а после чего и написать что-нибудь здесь!
Увы, некоторое время пришлось быть вне зоны доступа, для того, чтобы успешно сдать экзамены в аспирантуру, ну а кроме того, у меня не было вообще никаких идей по поводу того, о чем можно бы еще рассказать, учитывая тот факт, что обычно каждый наш пост выше по иерархической ступени интереса и опыта…
Но, все-таки, появилась идея, о чем еще я могу рассказать — и если вам интересно, то добро пожаловать в мир физических экспериментов, один из которых мы проведем используя D, как прекрасный анализатор нетривиальных «сигналов».
Для сегодняшнего DIY (Do It Yourself, или если по-русски, то «Сделай сам») нам потребуются в наличие следующие ингредиенты:
- плата Arduino (в моем случае, под рукой оказалась Arduino Uno);
- макетная плата
- проводок макетный
- ключ от квартиры или иной металлический предмет
- библиотека serial-port (где взять это чудо, описано в одной из предыдущих статей)
- паттерн «Одиночка»
- прямые руки
- трезвая голова
Первым делом, необходимо вспомнить, что в составе платы Arduino имеется такая замечательная штука, как ADC (Analog-Digital Converter, т.е аналого-цифровой преобразователь или сокращенно АЦП). Аналого-цифровой преобразователь переводит некоторый изменяющийся во времени сигнал (параметр, который может принимать некоторое значение из заранее известного диапазона, включающего максимальную и минимальную величину) в некоторое числовое значение (значение, которое находится также в известном, но дискретном, т.е. поделенном на некоторые равные части, диапазоне).
Однако, если не сильно вдаваться в детали, то можно сказать, что АЦП преобразует сигнал в некоторый двоичный код или некоторое числовое значение и именно этот факт нам сегодня потребуется для реализации одной интересной идеи…
Как известно, окружающее нас пространство, несмотря на кажущуюся пустоту, буквально заполнено огромным количеством электромагнитных сигналов самого разного происхождения, и что самое любопытное, среди всего этого электромагнитного хаоса наибольшим преимуществом обладают сигналы искусственного происхождения — их просто больше всего в эфире, да и мощность у них бывает на порядок выше.
Для того, чтобы как-то пощупать электромагнитный фон потребуется всего лишь небольшая антенна (которую мы даже рассчитывать не будем !) и устройство, способное принять и обработать поступивший сигнал (роль такого устройства сыграет связка Arduino + персональный компьютер): электромагнитный сигнал, пойманный примитивной антенной (кусок макетного провода), приведет к тому, что в проводе возникнет некоторая разность потенциалов, которую можно измерить, используя встроенный АЦП Arduino (помните, наверное, что электромагнитная волна представляет собой изменяющиеся во времени электрические и магнитные поля, действие которых на проводник приводит к возникновению в нем ЭДС).
После улавливания электромагнитной волны и преобразования ее в напряжение, необходимо узнать величину этого напряжения и передать полученную информацию в компьютер, а для этого потребуется вспомнить, что в Arduino Uno стоит 10-разрядный АЦП, умеющий переводить аналоговое напряжение в диапазоне от 0 до 5 Вольт. Путем несложных подсчетов, приходим к выводу, что весь ряд напряжений из этого диапазона представляется в виде целых чисел от 0 до 1023, а величина шага по напряжению составляет 0.00485 Вольт (соответственно, минимум какое напряжение можно поймать — это удвоенное значение этой величины), т.е. 5 Вольт / 1024 шага.
Таким образом, для того, чтобы как-то отобразить сигнал, нужно будет пронаблюдать за ним некоторое время, передавая в компьютер, значение напряжения на антенне, которое будет представлено некоторым числом от 0 до 1023 и которое будет принято и понято компьютерной программой.
Собственно, с помощью языка Wiring наша идея реализуется плавно и легко:
int value = 0; byte low = 0; byte high = 0; void setup() { Serial.begin(9600); } void loop() { value = analogRead(A0); low = value / 255; high = value % 255; Serial.write(low); Serial.write(high); }
Работает это просто: сначала мы создаем целочисленные переменные под значение, считанное с АЦП, «младшую часть» и «старшую часть» этого значения, после чего, настраиваем скорость передачи данных по COM-порту (она равна 9 600 бод, т.е все как по стандарту). Подготовив таким образом Arduino к работе, мы затем с помощью функции analogRead считываем значение с АЦП, после чего делим его на 255, присваивая результат деления «младшей части», а его остаток — «старшей части» и просто по очереди (запоминаем этот факт, позже он пригодится) передаем результаты этих манипуляций, как обычные байты с помощью метода write статического класса Serial (вроде бы, это статический класс в Wiring).
Здесь я должен дать некоторые пояснения: честно говоря, я уже не помню, какая часть называется младшей, а какая старшей, вы не поверите откуда взята идея посылать данные таким образом и за каким таким «макаром» пришлось так извращаться… Все дело в том, что write передает именно байт, а в него может влезть число из диапазона от 0 до 255 и поэтому пришлось изобретать «велосипед» по передаче числа, которое потенциально может не влезть в установленные байтом рамки (вот отсюда и берется пресловутое деление на 255).
А вот сама схема пришла мне в голову из моего первого опыта в программировании — из программирования на ZX Spectrum (не думайте, что я настолько стар — Spectrum я не застал, а потому не имел удовольствия работать с реальным устройством).
Ну, а теперь, после окончания прошивки платы, можно перейти к вопросам написания самой программы, которая будет как-то (пока не скажу как) обрабатывать поступившие с Arduino данные — и в первую очередь, нужно позаботиться о считывании данных с COM-порта, который является разделяемым ресурсом в нашей программе.
Раз COM-порт это действительно глобальный ресурс, то имеет смысл сделать его синглетным, для чего можно модифицировать код синглетного класса ClassicSingleton под текущую задачу и разместить в модуле comport:
module comport; import serial; class ClassicSingleton { private static SerialPort com; private static ClassicSingleton instance = null; protected this(string portname) { scope(exit) com = new SerialPort(portname); } public static ClassicSingleton getInstance(string portname) { if (instance is null) { instance = new ClassicSingleton(portname); } return instance; } public ubyte[256] read() { ubyte[256] buffer; try { com.read(buffer); } catch { } return buffer; } }
Функция read из библиотеки serial-port проста и занимается тем, что считывает в уже подготовленный буфер (некоторый пустой массив) данные, которые в нашем случае являются беззнаковыми (т.е. тип unsigned byte или в терминологии D, ubyte). Обратите внимание на то, что возвращает эта функция, а именно, массив из 256 беззнаковых байтов, что очень и очень критично для нашего проекта (объясню чуть погодя зачем выбран именно такой размер для возвращаемого значения).
После того, как нужные данные приняты, имеет смысл взяться за их качественную обработку, для чего потребуются некоторые знания из области под названием «цифровая обработка сигналов» (инженеры, извините, но тут я точно не специалист) и комплексной математики.
Обрабатывать сложный сигнал, полученный с Arduino будем с помощью преобразования Фурье, сделанного в форме быстрого преобразования (FFT — Fast Fourier Transform или Быстрое преобразование Фурье) и которое по сути дела описывает разложение некоего сигнала на элементарные составляющие — гармонические функции (т.е. синус, косинус и им подобные). FFT даст нам возможность пронаблюдать коэффициенты разложения сигнала и сделать некоторые выводы о его внутренней структуре, при этом само быстрое преобразование нам реализовывать не надо (присутствует в стандартной библиотеке в модуле std.numeric), однако, некоторые особенности его реализации знать необходимо.
Одной из особенностей FFT является то, что оно всегда принимает данные некоторой заданной длины, а именно длины, которая кратна степени двойки. Этим фактом, как раз и объясняется «странный» на первый взгляд результат возвращаемый методом read синглетного класса, используемого для работы с COM-портом на Arduino: мы делаем 128-точечное преобразование Фурье, которое достаточно точно и почти не сказывается на быстродействии итоговой программы.
Стоп! Почему 128-точечное, если размерность массива — 256?
Да все потому, что данные в нашем массиве представляют собой сдвоенные байты из которых мы и будем собирать уже итоговые значения, а поскольку всего таких «кусков» 256 и они содержат по два байта каждый, то общее количество точек (отсчетов) для Фурье составит 256 / 2, т.е. 128.
Итак, обработка данных преобразованием Фурье:
import std.complex; import std.math; import std.numeric; import comport; Complex!double[] processData(ClassicSingleton comPort) { ubyte[256] rawData = comPort.read(); float[] processedData; for (uint i = 0; i < 256; i += 2) { auto currentData = (255 * rawData[i] + rawData[i+1]) * (5.0 / 1024.0); processedData ~= currentData; } return fft(processedData); }
Как видите, результат преобразования является комплексным числом (причем типа double) и это, увы, не встроенный тип, что обозначает необходимость некоторых импортов (например, std.complex, который мы упоминали в статье про множество Мандельброта), которые также послужат нам для целей отображения данных в виде амплитудного спектра.
Амплитудный спектр легко получить из преобразования Фурье, просто напросто получив модуль комплексного коэффициента преобразования для чего используется функция abs:
double[] amplitudeSpectrum(Complex!double[] fftData) { import std.algorithm : map, max, reduce; import std.range : array; double[] cArgs = map!(a => abs(a))(fftData).array; double maximum = reduce!max(cArgs); return map!(a => a / maximum)(cArgs).array; }
Я думаю, вы уже поняли как работает этот хитроумный функциональный код (определенный, как и processData, в модуле processing): с помощью функции map мы получаем диапазон, состоящий целиком из уже готовых модулей, однако, это диапазон, а не массив, из-за чего и применяется метод array из std.range, который генерирует массив переменной длины из входного диапазона.
Чтобы было более наглядно, решено было немного отступить от простой отрисовки результата преобразования и сделать так, чтобы каждый коэффициент показывался в виде доли от некоторого максимального коэффициента. Для этого с помощью шаблона reduce, который осуществляет «свертку» (мы уже объясняли этот момент в статье об Icon и функциональном программировании в нем) и функции max, которая способна отыскать максимум для n предложенных ей значений, мы и находим максимальный коэффициент для модуля от FFT. Дальнейшее просто и тривиально, как никогда, вычисляем долю каждого коэффициента в максимуме, используя уже знакомые нам map и array.
Теперь осталось только отобразить результаты в красивом графическом окне, автоматически меняя их каждый раз, когда придет и обработается новая партия данных с Arduino. И именно здесь встает проблема: как автоматически обновить данные в нашем окне после завершения обработки сигнала?
Как ни странно это сделать можно довольно легко: перегрузим метод onPaint, определив в нем рисование результатов преобразования, а затем создадим таймер и в его событии будем вызывать метод формы invalidate (наследуется от класса Form), который вызывает принудительную перерисовку окна формы:
import dfl.all; import comport; import processing; class RTView: dfl.form.Form { private: ClassicSingleton port; Timer timer; void initializeRTView() { formBorderStyle = dfl.all.FormBorderStyle.FIXED_TOOLWINDOW; maximizeBox = false; text = "RTView"; clientSize = dfl.all.Size(512, 400); } void onTimerTick(Object sender, EventArgs ea) { this.invalidate(); } protected: override void onPaint(PaintEventArgs ea) { super.onPaint(ea); auto arguments = amplitudeSpectrum(processData(port)); foreach (index, elem; arguments) { auto Y = cast(int) (100 * elem); ea.graphics.fillRectangle(Color(0, 0, 250), Rect(index * 4, 300, 2, -Y)); } } override void onClosed(EventArgs ea) { timer.enabled = false; super.onClosed(ea); } public: this() { initializeRTView(); port = ClassicSingleton.getInstance("COM3"); timer = new Timer; timer.interval = 500; timer.tick ~= &this.onTimerTick; timer.enabled = true; } } int main() { int result = 0; try { Application.enableVisualStyles(); Application.run(new RTView); } catch(DflThrowable o) { msgBox(o.toString(), "Fatal Error", MsgBoxButtons.OK, MsgBoxIcon.ERROR); result = 1; } return result; }
Как видите, невизуальный компонент позволил решить проблему с отрисовкой в режиме почти реального времени (т.е. в квазиреальном времени, от латинского quasi — «якобы», «будто»), но в некотором пояснении нуждается структура и работа метода onPaint, представленного в вышеописанном листинге.
Метод onPaint сначала с помощью super вызывает метод базового класса (т.е. класса Form) с идентичным именем для того, чтобы правильно отобразить окно и другие его компоненты, после чего с помощью уже готовых процедур и приватных атрибутов происходит получение данных с COM-порта и отображение их в виде прямоугольников некоторой высоты, которая зависит от доли коэффициента спектра в максимуме этого спектра. Для того, чтобы это было более наглядно, величина каждого коэффициента умножается на 100 (помните, доля от коэффициента может находится в пределах от 0 до 1), а сами прямоугольники чертятся не от начала координат, а от линии с координатами (0, 300, 500, 300), что соответствует сдвигу начала координат в точку (0, 300).
Ну и напоследок полученный результат (если делать видео, то еще веселее выглядит):
само устройство:
и небольшая загадка:
Что будет, если до провода, подключенного к Arduino дотронуться пальцем? И откуда вообще берется этот сигнал?
А для особо любопытствующих дополнительные процедуры, рисующие фазовый спектр (phaseSpectrum) и спектр по энергии сигнала (energySpectrum):
double[] phaseSpectrum(Complex!double[] fftData) { import std.algorithm : map, max, reduce; import std.range : array; double[] cArgs = map!(a => arg(a))(fftData).array; double maximum = reduce!max(cArgs); return map!(a => a / maximum)(cArgs).array; } double[] energySpectrum(Complex!double[] fftData) { import std.algorithm : map, max, reduce; import std.range : array; double[] cArgs = map!(a => cast(double) (20 * log(abs(a))))(fftData).array; double maximum = reduce!max(cArgs); return map!(a => a / maximum)(cArgs).array; }
Кстати, можете убедиться сами, что результат преобразования Фурье, в данном случае от синусоиды, равен функции sin(x) / x, которую можно опознать на скриншоте программы.