приборы
82
0
25 декабря 2024
приборы

Готовимся к Micromouse: как роботу найти короткий путь к цели

Изображение создано
с помощью нейросети
Изображение создано с помощью нейросети
82
0
25 декабря 2024

Дениса Логашов, инженер-исследователь отдела автоматической обработки результатов моделирования и визуализации YADRO, продолжает рассказ о том, как программировать робота для соревнования Micromouse. Во второй части мы узнаем, как упростить функции робота с помощью модульной арифметики и, используя алгоритм заливки, научить «мышь» искать кратчайший путь до финиша как можно быстрее.

Из статьи вы узнаете
  • как упрощать функции робота с помощью модульной арифметики
  • как работает алгоритм заливки
  • как сэкономить время на исследовании лабиринта
  • как научить робомышь искать самый быстрый путь к финишу

Кратко опишу соревнование роботов Micromouse для тех, кто не читал первую часть. Есть квадратный лабиринт, поделенный на квадратные ячейки. В нем — фиксированные точки старта и финиша робота. Последняя — это, как правило, квадрат 2×2 ячейки с одним или несколькими входами. Задача робота-«микромыши» — как можно быстрее проехать от старта до финиша. На решение дается ограниченное время, за которое «мышь» может проходить лабиринт неограниченное число раз. Обновлять микрокод робота после демонстрации схемы лабиринта запрещено.

Обычно первый этап прохождения — это построение карты. Второй этап — это использование карты для поиска кратчайшего пути. Здесь потребуются более глубокие знания, чем на предыдущем этапе:

  • основы программирования: переменные, условия, циклы, функции, битовые операции,
  • основы модульной арифметики: взятие числа по модулю,
  • начальное понимание объектов в программировании.

Функции и переменные, которые мы использовали в прошлой части:

Упрощение функций

Сначала мы поработаем с функциями, чтобы их было удобнее использовать. Для понимания основной темы — алгоритма движения и поиска пути — это необязательно: можете сразу перейти к концу раздела.

В прошлой статье с помощью модульной арифметики мы сократили функцию для изменения направления робота внутри программы:

Теперь посмотрим на функцию выставления стен на карте, которую прописали там же:

Здесь с помощью модульной арифметики мы избавляемся от значительной части условий. Введем вспомогательный массив со всеми масками. Они записаны в определенном порядке: каждая соответствует направлениям, которые мы определили ранее.

wall_masks[4] = [up_wall, right_wall, down_wall, left_wall]

Посмотрим на соответствие между новым массивом масок и текущим направлением робота. Когда робот смотрит вверх (direction = 0), передний сенсор соответствует верхней стенке в ячейке, а маска этой стенки находится в массиве под индексом 0. Когда смотрит вниз (direction = 2), передний датчик робота соответствует нижней стенке с маской под индексом 2. Нетрудно догадаться, к чему я веду: чтобы записать показания с переднего датчика, необходимо выбрать маску с индексом текущего направления.

front_mask = wall_masks[direction]

Мы уже рассматривали, как направления соотносятся с поворотами робота. С датчиками все так же. Чтобы получить маску для левого датчика, необходим индекс (direction + left) % 4, для правого — (direction + right) % 4.

Обновляя стенки в одной ячейке, мы можем обновлять эти стенки и в соседних ячейках. При этом для соседних стенок маска должна быть противоположной: у верхней стенки соответствующая стенка в соседней ячейке — нижняя, у левой — правая. В массиве наших масок индекс таких пар отличается на 2.

Помимо маски, у нас меняется и координата, которой нам нужно задать значения. Мы уже знаем, как меняются координаты в зависимости от направления робота, это мы рассматривали в функции change_position ():

На основе этой функции мы можем сформировать массив изменения координат. Первый индекс в этом массиве будет соответствовать направлению робота, а второй — координатам (y, x):

Мы можем усовершенствовать нашу функцию изменения позиции с помощью нового массива:

Оптимизируем функцию обновления стенок. Сделаем так, чтобы она обновляла только одну стенку за раз. Важно определиться, относительно чего будем выбирать направление стенки, чтобы не запутаться с индексами. Будем считать, что мышь устанавливает информацию о стенках относительно себя — слева, спереди и т. д. — поэтому внутри функции нужно дополнительно учитывать текущее направление мыши:

В текущем виде эта функция неидеальна, так как мы используем в ней значения из direction_changes, но никак не проверяем результат. Можем столкнуться с тем, что потребуется выставить значения для стенок с индексами (-1, x) — очевидно, это неправильно. Как избежать таких ситуаций, рассмотрим далее.

Наш алгоритм прохождения лабиринта будет итеративно создавать карту лабиринта и строить дальнейший путь относительно того, какие стены обнаружит. Чтобы путь не менялся из-за ложных считываний с датчиков, сделаем следующее предположение: если робот побывал в ячейке и проверил стенки, то для этой ячейки он больше значения не меняет.

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

Для начала мы будем использовать эти маски при инициализации граничных стен:

Теперь еще раз взглянем на измененную функцию set_wall для выставления стен. Ее главная проблема в том, что мы могли получить невалидные индексы -1 или 5. Получить мы их могли лишь на крайних ячейках, когда проверяли граничные стенки. Поскольку мы условились не обновлять значения в ячейках, где уже побывали, граничные стены не должны выставляться повторно, а значит, и на невалидные индексы мы натыкаться не будем.

Чтобы это реализовать, нужно добавить проверку, была ли стенка уже выставлена или нет:

Теперь вместо отдельных функций для проверки разных направлений сделаем одну, которая будет проверять стенку по конкретному направлению. Эта функция нам понадобится не только при движении мыши, поэтому на вход мы будем передавать направление стены относительно лабиринта и координаты ячейки, в которой мы хотим проверить стенку.

Сведем то, что реализовали в этом разделе:

Построение карты весов

Подготовив основу для взаимодействия с картой, перейдем к тому, как робот будет передвигаться по лабиринту и анализировать стены. Напомню: робот знает, что он находится в углу, в какую сторону смотрит на старте и в какой ячейке находится финиш.

labyrinth example

Представим, что старт находится в ячейке (4, 0), а финиш в ячейке (0, 2). Изначально для робота карта имеет только внешние стены.

clean labyrinth map

Как нашему роботу решить, куда ехать? В этом нам поможет алгоритм FloodFill, или заливка. С его помощью мы будем «заливать» карту, чтобы понять, где находится ближайший путь к финишу. Используя данные заливки, робот будет стараться стать ближе к финишу после каждого движения.

Наша главная цель — не построить полную карту лабиринта, а как можно быстрее найти кратчайший путь. Мотивация простая: на соревнованиях время у команды ограничено, а полное исследование лабиринта размером 16×16 ячеек может затянуться. Так что мы будем искать самый короткий путь сразу, чтобы сохранить время для нескольких дополнительных заездов.

В чем суть заливки? Представим, что мы выливаем воду в ячейку финиша, и затем она начинает разливаться дальше по лабиринту. Чем ближе ячейка к финишу с учетом обхода стенок, тем раньше там окажется вода.

flood fill example

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

shortest path

В стартовой ячейке нам нужно указать число 6 — именно столько ячеек роботу нужно проехать до финиша. Когда он переместится на одну ячейку вверх, останется 5 ячеек. В финишной ячейке искомое расстояние — 0. Вот как должны быть расположены числа в нашем вспомогательном массиве:

weight examples

Как нам алгоритмически получить эти расстояния? Мы можем сказать, что финишная ячейка должна иметь значение 0. Именно от нее мы будем высчитывать все остальные значения. Мы будем брать каждого доступного соседа из ячейки и выставлять ему расстояние, равное расстоянию в текущей ячейке + 1. Для финишной ячейки соседями являются (0, 1), (1, 2), (0, 3).

flood fill starts

Для каждого из этих соседей мы установим значение равное 0 + 1, то есть 1.

flood fill goes

Теперь нужно проверить соседей для только что выставленных ячеек. Чтобы отслеживать ячейки, для которых нужно выставлять соседей, потребуется структура данных «очередь» (queue).

Эта конструкция работает по принципу «первый зашел — первый вышел». Мы будем добавлять ячейки в эту структуру в том же порядке, в котором обновляли значения. Затем брать из этой структуры первый элемент в списке и обновлять значения для его соседей. Так мы обеспечим равномерность заполнения карты.

В работе с очередью часто используют методы push () для добавления элемента в конец очереди и pop (), чтобы вытащить из очереди первый элемент. Пример работы такой конструкции:

Чтобы проверять, есть ли элементы в очереди, используют метод empty (). Он возвращает true, если в очереди нет элементов.

Вернемся к примеру лабиринта. В начале алгоритма у нас была пустая очередь, мы выставили значение 0 в ячейке (0, 2) и поместили ее в очередь.

cell queue

Дальше начинается наш алгоритм. Пока очередь не пуста, мы:

  • берем из нее ячейку,
  • выставляем значение «вес в текущей ячейке +1» для всех доступных соседей, у которых еще не было выставлено значение,
  • добавляем в очередь каждого обновленного соседа.

Вытаскиваем ячейку (0, 2) из очереди и обновляем каждого соседа, как условились выше. После этого помещаем соседей в очередь:

cell queue grows

Берем ячейку (0, 1), выбираем всех доступных соседей, которых еще не обновили. Обновляем расстояние для них и добавляем этих соседей в очередь.

 flood fill goes

Теперь очередь имеет следующий вид:

cell queue grows

Продолжаем выбирать ячейки из очереди и проверять соседей, пока полностью не заполним карту:

weight map completed

В итоге получаем карту с расстояниями, которая подскажет, сколько ячеек необходимо проехать до финиша из любой ячейки лабиринта. Важно помнить, что мы должны выбирать только доступных соседей, то есть между текущей ячейкой и соседней не должно быть стены. В самом простом примере наша карта пуста и мы с этим не сталкиваемся, но в алгоритме это должно быть обязательно предусмотрено.

Код функции для заполнения карты расстояний лабиринта:

Мы научились получать карту расстояний для каждой ячейки. Теперь у нас есть значения, по которым можно генерировать команды для робота. Достаточно следовать за убывающими значениями в карте весов — это и будет кратчайший путь.

Алгоритм поиска пути

Алгоритм поиска пути довольно прост: мы проверяем доступных соседей в ячейке и выбираем соседа с наименьшим значением. Оно всегда на 1 меньше, чем расстояние в текущей ячейке. Как только мы находим первое такое значение, то отправляем робота в эту ячейку.

Напишем функцию, которая будет возвращать направление, куда нам необходимо ехать, относительно лабиринта:

Однако тут появляется несостыковка. Функция возвращает направление относительно лабиринта, а не робота, которого нужно поворачивать. Мы знаем текущее направление робота, из переменной direction и направление, которое дает нам функция:

move_direction = find_path ()

Мы можем проверить каждое из направлений и посмотреть, как должна измениться переменная direction, чтобы соответствовать move_direction.Сейчас мы предполагаем, что робот физически может выполнять следующие действия: проехать одну ячейку вперед через forward (), повернуть на 90 градусов направо через turn_right () и налево через turn_left (). С учетом этого наша функция будет выглядеть так:

Алгоритм исследования лабиринта

Теперь у нас есть все детальки, чтобы собрать пазл — алгоритм, который позволит роботу найти путь до финиша.

Начнем с простого примера использования всех рассмотренных нами подходов. На старте наш робот знает лишь, что:

  • лабиринт имеет размер 5×5,
  • робот находится в позиции (4, 0), а финиш находится в позиции (0, 2).
labyrinth example

Вот как будет для него будет выглядеть стартовая заливка:

start fill

Заливка будет меняться в зависимости от расположения стен, так что мы будем обновлять карту весов каждый раз, когда проезжаем очередную ячейку и обновляем данные о стенах.

На начальной позиции робот считывает данные с датчиков и обновляет расположение стен. Веса при этом никак не меняются, поскольку для него все еще есть путь, где можно проехать за 6 ячеек. Посмотрим, как происходит заливка в этой ячейке:

start fill in process

Далее робот проезжает одну ячейку и оказывается в ячейке (3, 0) — это доступный сосед с меньшим значением, чем в текущей позиции. Там мы обнаруживаем другую стенку, но на заливку она не влияет.

flood fill second cell

Следующая доступная ячейка для робота — (3, 1). Здесь мы находим еще одну стену, которая опять не влияет на карту весов.

Следующая ближайшая к финишу ячейка, доступная для перехода — (2, 1).

flood fill fourth cell

Карта весов пока не изменилась, дальше робот может перейти в ячейку (2, 2). Здесь открывается сразу две новых стенки. На примере (2, 2) еще раз поэтапно взглянем на работу алгоритма заливки:

Повторяя этот алгоритм, робот переходит в доступные ячейки с меньшим весом, пока не окажется в ячейке с весом 0 — это и будет финиш. Вот карта, которую мы получили по итогам работы алгоритма исследования, и путь робота во время создания карты:

result map first walk

С этой картой робот может выбрать другой, более быстрый путь:

alternative path first walk

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

final weight map

Путь через левую сторону по длине оказывается таким же, как через правую. Нижний правый угол остался не исследован, так как это не может привести нас к более короткому пути. Без него мы сэкономили время, чтобы сделать быстрые заезды по короткому пути — во время них робот не тратит время на обновление карты весов и стен.

Хотя потребовалось два прохождения, чтобы проверить разные маршруты, наш подход все равно будет давать большую экономию по времени, особенно в более крупном лабиринте 16×16.

Воплощаем логику в коде

Опишем алгоритм из предыдущего раздела в виде функции. Напомню, что при помощи функций get_left_wall (), get_front_wall () и get_right_wall () робот считывает показания с датчиков, чтобы определить наличие стен.

Так наш робот сможет доехать от старта до финиша, при этом построив часть карты лабиринта и собрав информацию для быстрых заездов. Сводим все воедино.

На этом алгоритмическую часть, необходимую для участия в Micromouse, можно завершить. Осталось научить робота физически проезжать ровно одну ячейку, поворачивать на 90 градусов в любом направлении — и можно смело выступать в соревнованиях Micromouse.

Тем не менее останавливаться на этом алгоритме не стоит. Его можно оптимизировать и ускорять. Можно научить робота определять диагональные пути, что иногда значительно сокращает время прохождения. Наконец, стоит помнить, что самый короткий путь не всегда оказывается самым быстрым, а цель соревнования — проехать от старта до финиша быстрее всех.

Чтобы узнать больше о соревнованиях Micromouse в целом и о том, как научить робота строить карту лабиринта, прочитайте мою предыдущую статью.

Наверх
Будь первым, кто оставит комментарий