Вы когда-нибудь мечтали о 500-герцовой системе датчиков линейного положения? Тогда вам повезло — для этого достаточно печатной платы, простого микроконтроллера и немного математики!
См. также полный исходный код и журнал моих исследований по этому проекту.
▍ Зачем изготавливать штангенциркуль?
Электронный штангенциркуль — потрясающее устройство. Прибор за 30 долларов служил мне верой и правдой в течение нескольких лет, обеспечивая гораздо большую точность, чем требовалось моим навыкам:
В таком штангенциркуле используется ёмкостная связь между платой на электронном дисплее подвижной рамки и пассивной «шкалой» платы неподвижной штанги.
В марте этого года я неторопливо раздумывал над тем, что такой же принцип работы можно использовать для дешёвой системы позиционирования. Например, можно вставить пассивную шкалу в алюминиевый профиль, добавить ёмкостный датчик на низ того шасси, на котором он закреплён, и вуаля — у вас есть система определения позиции с обратной связью и разрешением меньше миллиметра. И всё это по цене печатной платы, нескольких контактов GPIO и прошивки (то есть, по сути, бесплатно).
Я подумал, что кто-то уже должен был сделать подобное, но не смог найти ни одного проекта «опенсорсного штангенциркуля». Самым близким к нему оказалась страница проекта на hackaday.io, созданная буквально за месяц до этого.
Я связался с её автором Mitko и предложил реализовать прошивку, если он отправит мне печатную плату. Моей основной мотивацией было желание изучить цифровую обработку сигналов, потому что я никогда не касался её, если не считать беглого упоминания преобразования Фурье в студенчестве.
Если вам просто нужны готовые способы получения точных измерений и вы не хотите углубляться в дебри, то можно рассмотреть следующие варианты:
- считывание данных измерений непосредственно с дешёвого штангенциркуля при помощи его секретного интерфейса передачи данных,
- поискать комплекты для digital read out (DRO) — это обобщённое название всевозможных ёмкостных, оптических и магнитных прецизионных линейных и угловых схем измерений (обычно для модернизации ручного станочного оборудования цифровым считыванием данных, чтобы вы сами могли быть ЧПУ). Например, можно использовать магнитный энкодер за 200 долларов с линейной лентой по цене 1 доллар за сантиметр.)
▍ Теория штангенциркулей
Вот фотография штангенциркуля, разобранного моим коллегой Mitko (обозначения добавлены мной):
Левая часть — это неподвижная штанга, содержащая пассивную плату с паттерном «отражателей» (они выглядят как заглавная буква T, повёрнутая на 90 градусов по часовой стрелке).
Справа находится электронный дисплей штангенциркуля, двигающийся вверх и вниз по металлической штанге; эта плата имеет длинную площадку приёмника и набор из площадок излучателей, похожих на клавиши пианино.
Если их собрать вместе (сложить, как книгу), основания букв Т отражателей окажутся над излучающими сигнал клавишами пианино, а перекладины — над площадкой приёмника.
(Примечание педанта: на самом деле, не происходит никакого «отражения», пластины имеют ёмкостную связь. Мне просто показалось, что термин «отражатель» передаёт нужный смысл.)
Вот увеличенное изображение из превосходного видео разборки штангенциркуля Big Clive (обозначения добавлены мной):
Верхняя часть — неподвижная штанга, нижняя часть — подвижная рамка.
Обратите внимание на следующие детали геометрии:
- Подвижная рамка излучает 8 сигналов через небольшие «клавиши пианино» (отмечены цифрами), повторяющиеся на протяжении всей длины.
- Основания отражателей имеют длину ровно 4 «клавиши».
По сути, пластина отражателя «складывает» сигналы находящихся под ним клавиш пианино (на этом фото сигналы 0, 1, 2 и 3).
Представьте, что мы сдвинули дисплей штангенциркуля на 0,5 клавиши вправо. Тогда отражатели будут находиться над половиной сигнала 0, целиком над сигналами 1, 2, 3 и над половиной сигнала 4. Если сдвинуться ещё на 0,5 клавиши вправо, то отражатели будут находиться ровно над сигналами 1, 2, 3 и 4.
Отражатели — это просто пассивные куски металла; они умеют лишь суммировать связанные с ними сигналы. Этот суммированный сигнал отражается на общую площадку приёмника подвижной рамки.
Какие же 8 сигналов мы должны излучать?
Если использовать синусоидальные волны одной частоты, но разных фаз, то их отражённая сумма всегда будет синусоидой исходной частоты с некой комбинированной фазой и амплитудой (доказательство).
То есть при перемещении подвижной рамки сдвиг фазы отражённого сигнала меняется.
Так как у нас есть 8 сигналов, мы поровну делим единичную окружность, чтобы n-сигнал был таким:
Тогда мы сможем отслеживать суммарный сдвиг фазы получаемого сигнала (относительно некой исходной позиции) и точно знать, что каждые сдвига в фазовом пространстве соответствуют линейному перемещению шириной в 8 клавиш излучателя.
▍ Реализация в микроконтроллере
Mitko отправил мне по почте версию 1.1 своей печатной платы (схема), изготовленную на основе микроконтроллера STM32F103.
Я написал прошивку при помощи фреймворка Embassy Rust, который работал достаточно хорошо. («Хорошо», насколько это возможно для встроенных устройств — мне пришлось выполнять побочный квест по поиску причин иногда возникавших зависаний, блокировавших отладчик; похоже, это аппаратный баг, проявляющийся только в старых версиях ARM Core.)
Прошивка должна выполнять следующие задачи:
- излучать 8 синусоид,
- измерять отражённую сумму и вычислять смещения фаз.
Давайте разбираться с этим по порядку.
▍ Излучение синусоид
STM32F103 не имеет цифро-аналогового преобразователя, но мы можем излучать синусоиду при помощи импульсно-плотностной модуляции (pulse density modulation, PDM). Эта техника схожа с широтно-импульсной модуляцией (ШИМ, PWM, pulse width modulation) тем, что уровень аналогового сигнала аппроксимируется «включением» цифрового сигнала на нужное время. Однако у PWM время «включённого» сигнала используется за раз, а сигнал PDM распределяет его в пределах окна сэмплирования.
Например, вот как можно представить аналоговый уровень 50% при помощи 8 импульсов:
PWM: X X X X . . . . PDM: X . X . X . X .
Сигнал PWM остаётся высоким (x
) в течение первой части периода, а сигнал PDM переключается. Это предпочтительно в нашем случае, потому что помехи переключения будут находиться на повышенной частоте, далеко от нашей низкочастотной синусоиды.
Нам нужно, чтобы все волны шли в строгом порядке друг за другом, поэтому мы будем обновлять контакты не один за другим, а единой 32-битной операцией записи в bit set/reset register (BSRR) GPIO.
Более того, поскольку мы заранее знаем, сколько сэмплов PDM нам требуется, мы можем пожалеть наш бедный маленький STM32F103 (у которого даже нет аппаратного модуля для выполнения операций с плавающей запятой) и вычислять все значения BSRR во время компиляции. Формальная система Rust времени компиляции не поддерживает тригонометрию, поэтому мы используем скрипт build.rs
для генерации строки кода во время компиляции:
fn generate_pdm_bsrr(n_samples: usize) -> String { let mut output = String::new(); output.push_str("pub const PDM_SIGNAL: [u32; "); output.push_str(&n_samples.to_string()); output.push_str("] = [\n"); let n_waves = 8; let mut errors = vec![0.0; n_waves]; for sample in 0..n_samples { let mut bsrr = 0u32; for wave in 0..n_waves { let phase_offset = 2.0 * PI * (wave as f64) / (n_waves as f64); let angle = 2.0 * PI * (sample as f64 / n_samples as f64) + phase_offset; let cosine = angle.cos() as f32; let normalized_signal = (cosine + 1.0) / 2.0; if normalized_signal > errors[wave] { bsrr |= 1 << wave; // устанавливаем бит errors[wave] += 1.0 - normalized_signal; } else { bsrr |= 1 << (wave + 16); // сбрасываем бит errors[wave] -= normalized_signal; } } output.push_str(&format!(" {:#034b},\n", bsrr)); } output.push_str("];\n"); output }
Затем эта сгенерированная строка записывается в файл, для которого мы обычным образом выполняем import
из пространства имён нашего основного кода. Затем константный срез PDM_SIGNAL
«запекается» в прошивку, а во время исполнения аппаратный таймер и задача DMA используются для копирования через фиксированные промежутки каждого значения напрямую в BSRR. Это позволяет избежать колебаний излучаемого сигнала, поскольку после начала передачи CPU больше в ней не участвует.
▍ Измерение фазового сдвига
Отражённая составная волна измеряется аналого-цифровым преобразователем (ADC) STM32F103. Задача DMA запускается одновременно с излучаемыми сигналами PDM и считывает из буфера фиксированное количество сэмплов.
Как же нам получить фазовый сдвиг?
Если вы похожи на меня, то первым делом бы захотели почитать учебники, чтобы понять, что же это такое. Я рекомендую Understanding Digital Signal Processing Ричарда Лайонса, потому что эта книга написана обыденным языком; также очевидно, что её написал опытный инженер: последняя глава — это просто 150 страниц «трюков с цифровой обработкой сигналов»!
Как мы уже знаем, наш отражённый сигнал — это сумма излучаемых нами синусоидальных сигналов, поэтому он тоже должен быть синусоидой с каким-то фазовым сдвигом; давайте назовём его (где — это некая константа, описывающая изменение амплитуды, вызванное ёмкостной связью, усилением и так далее, по сравнению с исходным излучаемым сигналом).
Как вы, наверно, помните из школьной формулы тригонометрического сложения, это можно переписать так:
Если выполнить корреляцию нашего сигнала с , то у нас остаётся , и аналогично для синуса.
Таким образом:
Сам оператор корреляции прост: это сумма произведений двух сигналов в соответствующих точках времени.
Нам достаточно лишь понять конкретное время сэмплов , которое можно вывести из частоты сэмплирования ADC.
Все члены, за исключением измеренных сэмплов сигналов , известны во время компиляции, поэтому мы снова можем сгенерировать таблицу поиска, которую будет использовать микроконтроллер:
fn generate_sine_cosine_table( signal_frequency: f64, sampling_frequency: f64, num_samples: usize, ) -> String { let mut output = String::new(); output.push_str("pub const SINE_COSINE_TABLE: [(f32, f32); "); output.push_str(&num_samples.to_string()); output.push_str("] = [\n"); for i in 0..num_samples { let angle = 2.0 * PI * signal_frequency * (i as f64 * (1.0 / sampling_frequency)); let sine = angle.sin() as f32; let cosine = angle.cos() as f32; output.push_str(&format!(" ({:?}, {:?}),\n", sine, cosine)); } output.push_str("];\n"); output }
Тогда скрипт build.rs будет иметь следующий вид:
fn main() { // программирование во время компиляции в буквальном смысле записью кода в файл, который мы импортируем let out_dir = std::env::var("OUT_DIR").unwrap(); let dest_path = std::path::Path::new(&out_dir).join("constants.rs"); let mut f = File::create(&dest_path).unwrap(); let pdm_frequency: u32 = 100_000; // 100 кГц f.write_all(format!("pub const PDM_FREQUENCY: u32 = {:?};\n", pdm_frequency).as_bytes()) .unwrap(); let pdm_length = 128; let num_samples = 512; let signal_frequency = pdm_frequency as f64 / pdm_length as f64; let adc_frequency = 12_000_000.; let adc_sample_cycles = 71.5; let adc_sample_overhead_cycles = 12.5; // см. раздел 11.6 справочного руководства let sampling_frequency = adc_frequency / (adc_sample_cycles + adc_sample_overhead_cycles); f.write_all( generate_sine_cosine_table(signal_frequency, sampling_frequency, num_samples).as_bytes(), ) .unwrap(); f.write_all(generate_pdm_bsrr(pdm_length).as_bytes()) .unwrap(); /// ... }
Наконец, во время исполнения у нас остаётся такой цикл:
loop { // запускаем излучение PDM через DMA // запускаем ADC через DMA // ждём, пока ADC считает NUM_SAMPLES // затем... let mut sum_sine: f32 = 0.0; let mut sum_cosine: f32 = 0.0; let adc_buf = unsafe { &ADC_BUF[..] }; for i in 0..NUM_SAMPLES { let (sine, cosine) = SINE_COSINE_TABLE[i]; sum_sine += adc_buf[i] as f32 * sine; sum_cosine += adc_buf[i] as f32 * cosine; } let phase = sum_sine.atan2(sum_cosine); // прибавляем последние показания фазы к оценке позиции. // этот объект также обрабатывает циклический возврат и гистерезис. position_estimator.update(phase); info!("Phase: {} Position: {}", phase, position_estimator.position); if user_button.is_low() { info!("Button pressed, zeroing"); position_estimator.position = 0.; } }
▍ Точность
Для реализации измерений в прошивке необходимо выбрать различные параметры:
- Частоту излучения импульсов PDM.
- Количество импульсов PDM, на которое мы делим один период синусоиды.
- Время сэмплирования ADC.
- Количество сэмплов ADC, считываемых при каждом вычислении фазы.
Вместо того, чтобы пытаться вычислить идеальные параметры из физических принципов (которые, вероятно, зависели бы от всевозможных тонкостей, например, от толщины паяльной маски платы и сопротивлений дорожек), давайте просто попробуем их все и выберем наиболее подходящие. Сформулируем запрос конкретнее: при какой из конфигураций параметров стандартное отклонение для множества измерений, сделанных при одном физическом положении подвижной рамки, будем наименьшим?
Вместо того, чтобы записывать прошивку заново для каждого набора параметров, мы можем написать специальную прошивку-рекордер, которой можно будет управлять через ноутбук. Так мы сможем передавать на ноутбук поток сырых показаний ADC, что позволит там протестировать гораздо более широкий диапазон параметров.
Все грязные подробности можно посмотреть в ноутбуке parameter sweep, а график выглядит так:
Тут много информации, так что давайте разберём её:
- Ось Y соответствует стандартному отклонению записанных фаз (чем меньше, тем лучше — рамка неподвижна, то есть в идеале все измерения должны возвращать одно и то же значение).
- Ось X соответствует частоте, для которой мы отправляем импульсы излучаемого синусоидного сигнала. Все данные здесь представлены для 128 сегментов PDM, а шаг по оси X равен 2 кГц.
- Каждый график соответствует отдельному «размеру окна», то есть количеству сэмплов ADC, с которым мы выполняем корреляцию для получения одного измерения фазы.
- Каждая цветная линия обозначает отдельную частоту сэмплирования ADC (ADC микроконтроллера STM32F103 может выполнять сэмплирование для 8 разных периодов, и мы пробуем их все).
Что показалось мне важным в самих данных:
- Чем выше частота ADC, тем выше должна быть частота PDM, чтобы мы могли начать получать сигнал (то есть для снижения стандартного отклонения). Это кажется мне логичным, ведь если частота ADC гораздо выше частоты сигнала, то наше окно сэмплирования вообще не увидит особых изменений в сигнале, поэтому там будет доминировать шум, и мы не будем иметь понятия, какая фаза.
- Увеличение размера окна обычно повышает точность — логично, потому что мы просматриваем большее количество сэмплов и (предположительно) снижаем влияние шума.
- Наименьшая частота сэмплирования ADC (то есть наибольший период сэмплирования ADC) обеспечивает наилучшие результаты.
- На 250 кГц присутствует странная «кошка»; вероятно, это точка, в которой наша частота сэмплирования ADC недостаточно быстра для самого сигнала. При 250 кГц/128 сегментах PDM наша излучаемая синусоида должна иметь частоту примерно 1950 Гц.
Хотя в этом статическом тесте лучше всего выглядел вариант с увеличением размера окна и периода сэмплирования ADC, это означает снижение частоты возможного вычисления фазы, что ограничивает скорость перемещения подвижной рамки — при её превышении рамка перестанет отслеживать абсолютную позицию.
Поэтому на основании этого теста я решил задать параметры локального вычисления внутри прошивки (показанного в демонстрационном видео) в точке, указанной красной стрелкой:
- размер окна = 128,
- частота PDM = 222 кГц,
- частота сэмплирования ADC = 222,2 кГц.
На самом деле, логично, что отклонение фаз здесь минимально: при этой частоте сэмплирования ADC и размере окна 128 мы практически соответствуем периоду излучаемого 128-сегментного сигнала PDM.
Метки времени в демонстрационном видео показывают, что между считываниями проходит примерно 1,5 мс (0,6 кГц), что почти в три раза больше идеального предела (222,2 кГц / 128 сэмплов => 1,7 кГц), вероятно, из-за времени, необходимого на выполнение вычислений корреляции, вывода на компьютер и работы асинхронного механизма Embassy. Я уверен, что более оптимизированная реализация могла бы достичь предела, например, при помощи вычисления корреляций во время сбора сэмплов, а не после.
Что касается точности, то, взяв из выведенных логов измерения фаз за 200 мс (n = 124), мы получим, что при неподвижной рамке стандартное отклонение фазы составляет 0,039 радиан; это даёт нам ошибку позиционирования (для моей PCB с 8 излучающими «клавишами» = 9,4 мм) примерно в .
Честно говоря, это гораздо лучше, чем я ожидал, особенно учитывая, что STM32F103 выпустили в 2007 году, управляющий сигнал создаётся простым «стучанием» в GPIO, а единственный способ согласования получаемого сигнала — это усилитель с постоянным коэффициентом усиления (мы даже не фильтруем 50-герцовый шум в линии).
▍ Мысли и полученные уроки
- Мне очень понравился процесс потоковой передачи сырых данных на компьютер через USB и выполнения анализа в ноутбуках Python. Он помог и в работе над проектом, когда я путешествовал и находился вдали от оборудования.
- Я не совсем хорошо ориентируюсь в экосистеме Python, поэтому задавал LLM Claude вопросы типа «Можешь сгенерировать график FFT этих данных?», «Пропусти эти данные через фильтр низких частот с отсечкой на 1000 Гц», «Можешь написать сумматор фаз, корректно обрабатывающий циклический возврат?» и так далее. Особенное удовольствие я получил от возможности использования моего легковесного приложения для голосового набора текста, при помощи которого можно просто сообщать свои мысли/идеи LLM.
- Но даже когда мне помогала LLM, создание графиков оказалось сложнее, чем я ожидал:
- Интерактивные графики на основе JS (Plotly и другие) ломались, когда я пытался визуализировать сырые данные всего с примерно сотней тысяч сэмплов.
- Библиотеки для создания статических графиков на основе matplotlib не позволяли простым способом интерактивно считывать точные координаты из точки на графике.
- Агрегация данных при помощи Polars выполнялась вполне неплохо, но для меня было неочевидно, как выполнять агрегацию + создавать график, например, и данных, и их стандартных отклонений между размерностями.
- Вероятно, мне просто нужно выбрать библиотеку на Python для создания графиков и потратить часов двадцать на её изучение.
- Распространение сигнатур комплексных типов в экосистеме Rust Embedded по-прежнему не радует — я застрял в локальном оптимуме «записывать буквально всё в
fn main()
с циклом в конце», чтобы не приходилось прописывать сигнатуры типов.
▍ Дальнейшие улучшения
Вероятно, я не буду развивать этот проект, пока не найду какой-нибудь контекст его применения в робототехнике. Но если вы придумали, как его использовать, то вот несколько идей:
- Разобраться, как создавать параметрические конструкции излучателей/отражателей штангенциркуля в приложениях для моделирования печатных плат наподобие atopile или в каком-нибудь скрипте генерации KiCAD.
- Спроектировать что-нибудь специально для работы с подвижными системами на основе алюминиевых профилей и проверить, насколько дешёвыми/точными они могут быть.
- Сравнить с более «стандартными» системами позиционирования, например, с магнитной лентой или с трекерами SteamVR.
- Превратить это в настоящий работающий штангенциркуль, спроектировав более подходящий корпус (из моего видео можно понять, что изготовленное 3D-печатью основание просто закреплено у меня на столе).
▍ Благодарности
- Mitko за проектирование печатной платы и оборудования. Подробности см. на странице проекта hackday.io.
- Нейтану Перри и Джеффу Макбрайду за помощь в выявлении причины произвольных зависаний STM32F103: бага в кремнии CPU. (Желаю всем нам постараться не столкнуться с ним снова!).
Telegram-канал со скидками, розыгрышами призов и новостями IT 💻
ссылка на оригинал статьи https://habr.com/ru/articles/858240/
Добавить комментарий