Roboost – Primary Motor Cortex

In this post, I want to showcase the newest update on the Roboost software. I will give a short overview of the general changes in structure and goals and then explain the specific example application on my mecanum wheeled robot.

General Updates on the Roboost Software

While working on the new firmware, I had the opportunity to think about where I want to go with this project. Roboost only used to be the name of my robots, as you can see in the following two posts:

But this is changing now, as from now on, the whole software stack, independent of the specific hardware will be called Roboost. This is due to the new goals of the project:
  • Minimize the cost of robotic development with ROS2
  • Provide an easy-to-use base for more complex systems
  • Configurable for different types of robots

Here are a few points on how these goals are accomplished:

  • Β The core parts of the firmware can be run on an ESP32, with a ROS2 integration that enables UDP communication over WiFi (so there is no need for an expensive onboard processor)
  • The software is separated in modules which can be swapped out and configured for the specific hardware of the robot
  • PlatformIO is used for the firmware projects, which is more easy to use and accessible than other frameworks
  • ROS2 integration enables usage of existing packages for more complex systems
  • The code is written with readability, maintainability and scalability in mind
  • All projects (will) include an extensive Doxygen documentation
This GitHub repository is the main place where all the magic happens. Here you can find all the code, documentation and soon also the hardware files for the different robot projects. If you know a better way of sharing the hardware files, please let me know in the comments! πŸ™‚

Primary Motor Cortex

I know, wired name, but hear me out. The naming of all the different Roboost components should orient on the human brain. Different Roboost components will be named after the region of the brain with a similar function. So for example, in the case of the component concerned with low-level motor control, the area of the brain that is most fitting is the Primary Motor Cortex. Similar to the corresponding brain area, the component is not responsible for motion planning, but only for executing the motion commands given by higher-level components. In the case of the Roboost project, these motion commands are in the form of ROS2 twist messages on the cmd_vel topic.

The Primary Motor Cortex component is structured, so that it is easily extendable for different hardware implementations. It can be configured to use UDP or UART for communication, that way, a board computer is optional. Virtual base classes are used to specify things most robot designs will need to implement (like kinematics or motor control), which then are implemented for specific use-cases.

As an example, we can have a look at the kinematics.hpp file:

/**
 * @file kinematics.hpp
 * @author Jakob Friedl (friedl.jak@gmail.com)
 * @brief Kinematics base class definitions.
 * @version 1.1
 * @date 2023-08-21
 *
 * @copyright Copyright (c) 2023
 *
 */

#include <BasicLinearAlgebra.h>

#ifndef KINEMATICS_H
#define KINEMATICS_H

/**
 * @brief Abstract base class for defining kinematics calculations.
 *
 * This class provides an interface for calculating robot velocity and wheel velocity
 * based on the kinematic properties of the robot.
 */
class Kinematics
{
public:
    /**
     * @brief Calculate robot velocity based on wheel velocities.
     *
     * @param wheel_velocity The velocities of individual wheels.
     * @return BLA::Matrix<3> The calculated robot velocity.
     */
    virtual BLA::Matrix<3>
    calculate_robot_velocity(const BLA::Matrix<4>& wheel_velocity) = 0;
    
    /**
     * @brief Calculate wheel velocities based on robot velocity.
     *
     * @param robot_velocity The velocity of the robot.
     * @return BLA::Matrix<4> The calculated wheel velocities.
     */
    virtual BLA::Matrix<4>
    calculate_wheel_velocity(const BLA::Matrix<3>& robot_velocity) = 0;
};

/**
 * @brief Mecanum kinematics for a 4-wheel robot.
 *
 * This class implements the kinematics calculations for a 4-wheel mecanum drive robot.
 * It takes into account the wheel radius, wheel base, and track width of the robot.
 */
class MecanumKinematics4W : public Kinematics
{
public:
    /**
     * @brief Construct a new Mecanum Kinematics 4W object with given parameters.
     *
     * @param wheel_radius The radius of the wheels.
     * @param wheel_base The distance between wheel contact points in the x direction.
     * @param track_width The distance between wheel contact points in the y direction.
     */
    MecanumKinematics4W(double wheel_radius, double wheel_base,
                        double track_width);

    /**
     * @brief Calculate robot velocity based on wheel velocities.
     *
     * @param wheel_velocity The velocities of individual wheels.
     * @return BLA::Matrix<3> The calculated robot velocity.
     */
    BLA::Matrix<3>
    calculate_robot_velocity(const BLA::Matrix<4>& wheel_velocity) override;
    
    /**
     * @brief Calculate wheel velocities based on robot velocity.
     *
     * @param robot_velocity The velocity of the robot.
     * @return BLA::Matrix<4> The calculated wheel velocities.
     */
    BLA::Matrix<4>
    calculate_wheel_velocity(const BLA::Matrix<3>& robot_velocity) override;

private:
    double wheel_radius_; // Radius of the wheels.
    double wheel_base_;   // Distance between wheel contact points in the x direction.
    double track_width_;  // Distance between wheel contact points in the y direction.
};

// Add more kinematics definitions here

#endif // KINEMATICS_H" 

The implementation of the mecanum kinematics can then be found in src/kinematics/mecaum_kinematics_4w.cpp:

/**
 * @file mecanum_kinematics_4w.cpp
 * @author Jakob Friedl (friedl.jak@gmail.com)
 * @brief Implementation of MecanumKinematics4W class.
 * @version 1.1
 * @date 2023-07-06
 *
 * @copyright Copyright (c) 2023
 *
 */
#include "kinematics/kinematics.hpp"

MecanumKinematics4W::MecanumKinematics4W(double wheel_radius, double wheel_base,
                                         double track_width)
    : wheel_radius_(wheel_radius), wheel_base_(wheel_base),
      track_width_(track_width)
{
}

BLA::Matrix<3> MecanumKinematics4W::calculate_robot_velocity(
    const BLA::Matrix<4>& wheel_velocity)
{
    BLA::Matrix<3> robot_velocity;

    // clang-format off
    BLA::Matrix<3, 4> inverseKinematicsModel = { 1, 1, 1, 1, 
                                                -1, 1, 1, -1, 
                                                -1/(wheel_base_ + track_width_), 1/(wheel_base_ + track_width_), -1/(wheel_base_ + track_width_), 1/(wheel_base_ + track_width_)};
    // clang-format on
    robot_velocity = inverseKinematicsModel * wheel_velocity;
    robot_velocity *= wheel_radius_ / 4;

    return robot_velocity;
}

BLA::Matrix<4> MecanumKinematics4W::calculate_wheel_velocity(
    const BLA::Matrix<3>& robot_velocity)
{
    BLA::Matrix<4> wheel_velocity;

    // clang-format off
    BLA::Matrix<4, 3> forwardKinematicsModel = { 1, -1, -(wheel_base_ + track_width_),
                                                1, 1, wheel_base_ + track_width_,
                                                1, 1, -(wheel_base_ + track_width_),
                                                1, -1, wheel_base_ + track_width_};
    // clang-format on

    wheel_velocity = forwardKinematicsModel * robot_velocity;
    wheel_velocity *= 1 / wheel_radius_;

    return wheel_velocity;
} 

As you can see by the commenting style, Doxygen is used for documentation. Soon, a post will follow in which I describe my VS Code stack for developing robotics applications in which I will go in a bit more detail about Doxygen, clang-format, and so on.

The provided structure allows the code to be nicely readable, as can be seen in the core.cpp file below. At first, all necessary includes are declared. Then a L298N motor driver object is created (which is a implementation of the abstract motor driver class). Each motor of the robot is declared that way and then used as a parameter for the simple motor controller object. It is again an implementation of the motor controller and named “simple”, as it does not use the encoders as feedback, but simply sets the output to a desired value. A more powerful motor controller will soon be available, which utilizes a PID controller to incorporate the encoder feedback.

These motor controllers are then used to initialize the motor control manager. This class is used to provide a nice interface to set and retrieve all the motor speeds and positions, also in a BLA vector format. This object is used together with the kinematics to create the robot controller object. The rest of the code is mostly micro-ROS specific and handles the ROS communication. More information on micro-ROS can be found on the dedicated post, or the official documentation.

Β 

/**
 * @file core.cpp
 * @author Friedl Jakob (friedl.jak@gmail.com)
 * @brief This file contains the main functionality for controlling a mecanum robot using micro-ROS.
 * @version 1.1
 * @date 2023-08-21
 *
 * @copyright Copyright (c) 2023
 *
 */

#include <Arduino.h>
#include <micro_ros_platformio.h>

#include "rcl_checks.h"
#include <rcl/rcl.h>
#include <rclc/executor.h>
#include <rclc/rclc.h>

#include <geometry_msgs/msg/twist.h>
#include <nav_msgs/msg/odometry.h>

#include "conf_network.h"
#include "conf_robot.h"
#include "motor_control/motor_drivers/l298n_motor_driver.hpp"
#include "motor_control/simple_motor_controller.hpp"
#include "robot_controller.hpp"

L298NMotorDriver driver_M0(M0_IN1, M0_IN2, M0_ENA, M0_PWM_CNL);
L298NMotorDriver driver_M1(M1_IN1, M1_IN2, M1_ENA, M1_PWM_CNL);
L298NMotorDriver driver_M2(M2_IN1, M2_IN2, M2_ENA, M2_PWM_CNL);
L298NMotorDriver driver_M3(M3_IN1, M3_IN2, M3_ENA, M3_PWM_CNL);

SimpleMotorController controller_M0(driver_M0, 1.);
SimpleMotorController controller_M1(driver_M1, 1.);
SimpleMotorController controller_M2(driver_M2, 1.);
SimpleMotorController controller_M3(driver_M3, 1.);

MotorControllerManager motor_controll_manager{
    {&controller_M0, &controller_M1, &controller_M2,
     &controller_M3}}; // initializer list

// initialize kinematics
MecanumKinematics4W kinematics(WHEELRADIUS, WHEEL_BASE, TRACK_WIDTH);
RobotController robot_controller(motor_controll_manager, kinematics);

rcl_subscription_t subscriber;
rcl_publisher_t publisher;
geometry_msgs__msg__Twist msg;
nav_msgs__msg__Odometry odom;

rclc_executor_t executor;
rclc_support_t support;
rcl_allocator_t allocator;
rcl_node_t node;

/**
 * @brief Callback function for handling incoming cmd_vel (velocity command) messages.
 *
 * @param msgin Pointer to the received geometry_msgs__msg__Twist message.
 */
void cmd_vel_subscription_callback(const void* msgin)
{
    const auto* msg = reinterpret_cast<const geometry_msgs__msg__Twist*>(msgin);

    // Convert the ROS Twist message to a BLA::Matrix<3>
    BLA::Matrix<3> cmd;
    cmd(0) = msg->linear.x;
    cmd(1) = msg->linear.y;
    cmd(2) = msg->angular.z;

    // Serial.print("Linear X: ");
    // Serial.println(cmd(0));
    // Serial.print("Linear Y: ");
    // Serial.println(cmd(1));
    // Serial.print("Angular Z: ");
    // Serial.println(cmd(2));

    robot_controller.set_latest_command(cmd);
}

/**
 * @brief Setup function for initializing micro-ROS and robot control parameters.
 *
 */
void setup()
{
    // Configure serial transport
    // Serial.begin(115200);

    IPAddress agent_ip(AGENT_IP);
    uint16_t agent_port = AGENT_PORT;

    set_microros_wifi_transports(SSID, SSID_PW, agent_ip, agent_port);
    delay(2000);

    allocator = rcl_get_default_allocator();

    // create init_options
    // Serial.println("Creating init options...");
    while (rclc_support_init(&support, 0, NULL, &allocator) != RCL_RET_OK)
    {
        // Serial.println("Failed to create init options, retrying...");
        delay(1000); // Wait for 1 second
    }

    // Serial.println("Creating node...");
    while (rclc_node_init_default(&node, "roboost_core_node", "", &support) !=
           RCL_RET_OK)
    {
        // Serial.println("Failed to create node, retrying...");
        delay(1000); // Wait for 1 second
    }

    // Serial.print("Creating publisher...");
    while (rclc_publisher_init_default(
               &publisher, &node,
               ROSIDL_GET_MSG_TYPE_SUPPORT(nav_msgs, msg, Odometry),
               "odom") != RCL_RET_OK)
    {
        // Serial.println("Failed to create publisher, retrying...");
        delay(1000); // Wait for 1 second
    }

    // Serial.println("Creating subscriber...");
    while (rclc_subscription_init_default(
               &subscriber, &node,
               ROSIDL_GET_MSG_TYPE_SUPPORT(geometry_msgs, msg, Twist),
               "cmd_vel") != RCL_RET_OK)
    {
        // Serial.println("Failed to create subscriber, retrying...");
        delay(1000); // Wait for 1 second
    }

    // Serial.println("Creating executor...");
    while (rclc_executor_init(&executor, &support.context, 1, &allocator) !=
           RCL_RET_OK)
    {
        // Serial.println("Failed to create executor, retrying...");
        delay(1000); // Wait for 1 second
    }

    // Serial.println("Adding subscriber to executor...");
    while (rclc_executor_add_subscription(&executor, &subscriber, &msg,
                                          &cmd_vel_subscription_callback,
                                          ON_NEW_DATA) != RCL_RET_OK)
    {
        // Serial.println("Failed to add subscriber to executor, retrying...");
        delay(1000); // Wait for 1 second
    }

    delay(500);
    pinMode(LED_BUILTIN, OUTPUT);
    digitalWrite(LED_BUILTIN, HIGH);
}

/**
 * @brief Main loop for continuously updating and publishing robot's odometry.
 *
 */
void loop()
{
    // Publish the RobotController's latest odometry
    robot_controller.update();
    BLA::Matrix<6> odometry = robot_controller.get_odometry();

    // Convert the odometry matrix to a nav_msgs__msg__Odometry
    odom.pose.pose.position.x = odometry(0); // x position
    odom.pose.pose.position.y = odometry(1); // y position
    odom.pose.pose.orientation.z =
        odometry(2); // yaw orientation (using z-axis rotation)

    odom.twist.twist.linear.x = odometry(3);  // x linear velocity
    odom.twist.twist.linear.y = odometry(4);  // y linear velocity
    odom.twist.twist.angular.z = odometry(5); // z angular velocity

    RCSOFTCHECK(rcl_publish(&publisher, &odom, NULL));

    RCSOFTCHECK(rclc_executor_spin_some(&executor, RCL_MS_TO_NS(100)));
    delay(100);
} 

As you can guess, the code simply listens to commands on the /cmd_vel topic controls the wheels accordingly, and publishes the odometry data of the encoders.

The following video shows a little demonstration (after watching the video myself, I found out that I have a really boring voice, so please take a sip of coffee before clicking play to a avoid falling asleep) πŸ˜‰

Conclusion

I’m pretty happy with the current structure of the project. Soon, a post will follow on the entorhinal cortex, the part of the robot that will be used for rudimentary sensor data acquisition. For that, I’m currently running a LiDAR and IMU with an ESP32 and sending the data to a PC in the network which runs Gazebo, but the code still needs some cleaning up to do.

Further tasks for the primary motor cortex are:

  • Provide extensive documentation on the current system and how to extend it
  • Implement more advanced motor control algorithms
  • Add support for BLDC motor drivers

I would be honoured if you could star the GitHub repo! This will show me that there is interest in this! πŸ™‚

Also, if you have any questions, suggestions or just want to get in touch, I would genuinely love to hear your thoughts in the comments!

Thanks for sticking around! πŸ™‚

Subscribe
Notify of
guest
0 Comments
Inline Feedbacks
View all comments