Closed22

mbedによるロボット制御

1111
  • NucleoF446REの説明
  • 環境構築手順
  • main.cppをつくる
  • デフォルト設定の上書き
  • 接続、書き込み
  • mbed OSのAPIを使いこなす
    • DigitalOut
    • DigitalIn
    • PwmOut
    • Wait
    • HighResClock
    • CAN
  • Lチカ
  • C620
  • オムニ
1111

Nucleo F446RE

非常に優秀なマイコン。Arduinoよりこいつを使うべき。[1][2]

  • ピン数が多い
  • 計算速度が早い(最大クロック180MHzかつFPU付き)
  • CANが喋れる
  • 全ピン割り込み対応(16chまで)
  • ユニバーシティプログラムで無料配布される

ピンアサインについて、基本的にはMbedが公開しているサイトを見ながら決める。
紺色で示されたピンなどデフォルトでは使用できないピンがあるので注意すること。[3]

リセットは黒いボタン。
四角形のLEDはステータスLEDとなっている。

Nucleo F303K8

小さめのNucleo。[4][5]
CAN MDなどのECUにこのチップを使用しがち。
ピン数と性能は少し下がるが、ユニバーシティプログラムで大量にもらうことができる。

脚注
  1. https://os.mbed.com/platforms/ST-Nucleo-F446RE/ ↩︎

  2. https://www.st.com/ja/evaluation-tools/nucleo-f446re.html ↩︎

  3. https://keiorogiken.wordpress.com/2018/12/08/nucleo-f446re/ ↩︎

  4. https://os.mbed.com/platforms/ST-Nucleo-F303K8/ ↩︎

  5. https://www.st.com/ja/evaluation-tools/nucleo-f303k8.html ↩︎

1111

Nucleo G474RE

F446REより優秀かもしれん。
値段は200円くらいしか変わらず、FDCANが3つ使える。
しかも三角関数の計算を早くできる。
ただしMbed OSではこいつの性能を活かし切ることができないので、全性能を活かしたければCubeのHALを使うことになる。

https://os.mbed.com/platforms/ST-Nucleo-G474RE/

1111

環境構築手順

VS Code

VS Codeはプログラムを書くためのエディタ。
ダウンロードリンク

Windows向け
ダウンロードリンクよりインストーラをダウンロード。
インストーラを実行してインストール。
WindowsはPythonも必要?

Ubuntu
ダウンロードリンクよりcode_<バージョンとかの文字>.debをダウンロード。
コマンドよりインストール。

sudo apt install code_<バージョンとかの文字>.deb

Platform IO拡張機能

Platform IO(以降pio)というVS Codeの拡張機能を入れる。
pioはマイコン向けのプログラムを書くために必要。

ここを見て拡張機能をインストールする。

Linuxユーザ向け追加手順

Linuxでpioを使用するためにはpython3-venvが必要なので追加でinstallする。

Ubuntuなら下記コマンドでインストール可能。

sudo apt install python3-venv

python3-pipも必要かも?

プロジェクトを作成。

「PlatformIO Home」の「New Project」をクリック。
Boardを「Nucleo F446RE」、Frameworkを「Mbed」として作成。

1111

main.cppをつくる

プロジェクトを作成すると以下のようなディレクトリ構造が作成される。

.
├── include/
│   └── README
├── lib/
│   └── README
├── src/
│   └── main.cpp
├── test/
│   └── README
└── platformio.ini

src/main.cppを開いて以下を貼り付ける。もし無ければ作る。

src/main.cpp
#include <mbed.h>

int main() {
  // put your setup code here, to run once:
  // 1度しか走らないセットアップのコードをここに書く。
  printf("setup\n");

  while(1) {
    // put your main code here, to run repeatedly:
    // 繰り返し実行するメインのコードをここに書く。

  }
}
1111

デフォルト設定の上書き

platformio.iniは以下のような感じになっていると思う。(一部省略)

platformio.ini
[env:nucleo_f446re]
platform = ststm32
board = nucleo_f446re
framework = mbed

これを編集して下記の通りにする。

platformio.ini
+[platformio]
+build_cache_dir = .cache

 [env:nucleo_f446re]
 platform = ststm32
 board = nucleo_f446re
 framework = mbed
+monitor_speed = 115200
+monitor_filters =
+	log2file
+	time
+	default
+build_flags = -std=gnu++17
+build_unflags = -std=gnu++14

mbed_app.jsonをつくる

mbedのデフォルト設定では、ビルドが遅い&printfが貧弱なので設定を上書きする。[1]
mbed_app.jsonというファイルを作成して、下記内容を貼り付け。

mbed_app.json
{
  "requires": [
    "bare-metal"
  ],
  "target_overrides": {
    "*": {
      "target.c_lib": "std",
      "platform.minimal-printf-enable-64-bit": true,
      "platform.minimal-printf-enable-floating-point": true,
      "platform.minimal-printf-set-floating-point-max-decimals": 6,
      "platform.stdio-baud-rate": 115200
    }
  }
}
上記設定の説明
  • build_cache_dir = .cacheによってビルドにキャッシュを使用。これで2回目以降のビルドが高速化する。使用しないキャッシュは容量を食う原因なので、容量が気になるなら.cacheをディレクトリごと消せばよい。
    ビルドがうまくいかないときも.cacheを削除すると解決することがある。
  • monitor_speedplatform.stdio-baud-rateによってマイコンとPCの通信速度を9600→152000に上げる。
  • monitor_filtersでログの時間を表示、ログをファイルに残す。
  • build_flagsbuild_unflagsでバージョンをC++14→C++17とする。ここはお好みだがC++14だと使いたい機能が使えないことが多い。build_flags = -std=gnu++2aとすればC++20の機能も一部使える。またC++17にするとWregisterで警告がでるが無視してよい。気になるならextra_scriptsを使用する。
  • "requires""bare-metal"のみとすることで、不要なWiFi等の機能を省く。必要なら各自追加のこと。
  • "platform.minimal-printf-***"のあたりはfloat等のprintf出力を有効化するもの。メモリ使用量が気になるなら消してよい。
脚注
  1. https://os.mbed.com/docs/mbed-os/v6.16/program-setup/advanced-configuration.html ↩︎

1111

接続、書き込み

Linuxの場合

pioのドライバをインストール

curl -fsSL https://raw.githubusercontent.com/platformio/platformio-core/develop/platformio/assets/system/99-platformio-udev.rules | sudo tee /etc/udev/rules.d/99-platformio-udev.rules

https://docs.platformio.org/core/installation/udev-rules.html#platformio-udev-rules

USBケーブルでマイコンとPCを繋ぐ。
するとpioがマイコンを認識するので左下の「Upload」ボタン、またはCtr+Alt+Uを押して自身の書いたプログラムをマイコンに書き込む。

右下の「Monitor」ボタン、またはCtr+Alt+Mを押してシリアルモニタを起動する。そしてマイコンのリセットボタンを押す度に1度だけ"setup"と表示される。

1111

Lチカ

Nucleo F446REにはユーザボタンとLEDが乗っているため、それらを利用してLチカを行う。

API Listより、DigitalInDigitalOutを見つけ出して開く。

コード例を参考にしつつ、ユーザボタンが押されていない間のみLEDが点灯するコードを書く。

コード例
src/main.cpp
#include <mbed.h>

DigitalIn button{BUTTON1};
DigitalOut led{LED1};

int main() {
  // put your setup code here, to run once:
  printf("setup\n");

  while(1) {
    // put your main code here, to run repeatedly:
    if(!button) {
      led = !led;
      ThisThread::sleep_for(100ms);
    }
  }
}

PwmOutを使用してボタンが押されるたびにLEDの明るさが変わるコードを書く。

コード例
src/main.cpp
#include <mbed.h>

DigitalIn button{BUTTON1};
DigitalOut led{LED1};

bool pre_button = false;

int main() {
  // put your setup code here, to run once:
  printf("setup\n");

  while(1) {
    // put your main code here, to run repeatedly:
    if(!pre_button && button) {
      float out = led.read() + 0.1;
      if(out > 1) {
        out = 0;
      }
      led = out;
      ThisThread::sleep_for(100ms);
    }
  }
}
1111

Waitについて

MbedはWait APIも提供している。

使用できる関数は

  • ThisThread::sleep_for(chrono::duration sleep_duration)
  • wait_us(int us)
  • wait_ns(unsigned int ns)

使用例

// 1秒待つ
ThisThread::sleep_for(1s);
// 10ミリ秒待つ
ThisThread::sleep_for(10ms);
// 100マイクロ秒待つ
ThisThread::sleep_for(100us);

// 15マイクロ秒待つ
wait_us(15);
// 250ナノ秒待つ
wait_ns(250);

waitを使用してしまうと他のコードの実行をブロックしてしまうので注意する。HighResClockとif文を組み合わせれば、センサ読み取りなどを続けたままwaitを行うことができる。

HighResClock

HighResClockはマイコンが起動してからの経過時刻を取得できるAPI。
std::chronoのClockと同じはたらきをする。

現在時刻と前回実行時の時刻を比較することで、一定時間ごとに実行するコードを作成できる。

src/main.cpp
#include <mbed.h>

int main() {
  // put your setup code here, to run once:
  printf("setup\n");

  while(1) {
    // put your main code here, to run repeatedly:
    auto now = HighResClock::now();
    static auto pre = now;

    printf("ここでブロックされない処理を実行する\n");

    // 10msごとに実行↓
    if(now - pre > 10ms) {
      printf("ここで10msごとに実行する\n");

      pre = now;
    }
  }
}
1111

CAN

CAN APIを使ってCAN通信を行う。

CAN Busの初期化

下記コードでCAN1を初期化。通信速度は1MHzに設定する。

#include <mbed.h>
// CAN1を使う場合
CAN can{PA_11, PA_12, (int)1e6};
// CAN2を使う場合はこっち
CAN can{PB_12, PB_13, (int)1e6};

CANMessage構造体の作成

CANMessage構造体によってメッセージの授受を行う。

// 中身が空のメッセージ
CANMessage msg;
// 送信データの作成
uint32_t id = 0x200;
uint8_t data[8] = {0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07};
// CANMessage構造体の作成
CANMessage msg{id, data, sizeof(data)};
// 送信データの作成(int16_t)
uint32_t id = 0x200;
int16_t data[4] = {4000, 2000, 0, -5000};
// CANMessage構造体の作成
CANMessage msg{id, reinterpret_cast<const uint8_t *>(data), sizeof(data)};

CANメッセージの送信

can.write関数に送信したいメッセージ(msg)を渡すことでCANメッセージの送信を行う。
can.write(msg)

while(1) {
  // put your main code here, to run repeatedly:
  auto now = HighResClock::now();
  static auto pre = now;
  // 10msごとに実行
  if(now - pre > 10ms) {

    // 送信データの作成
    uint32_t id = 0x200;
    uint8_t data[8] = {};
    // CANMessage構造体の作成
    CANMessage msg{id, data, sizeof(data)};

    for(int i = 0; i < 8; ++i) {
      printf("%2x", data[i]);
    }

    // メッセージの送信
    if(!can.write(msg)) {
        printf("failed to write can msg");
    }

    printf("\n");
    pre = now;
  }
}

CANメッセージの受信

can.read(msg)を使用して送られてきたCANメッセージを読み取る。
can.read関数はメッセージを受け取ったときだけ、msgの中身を上書きしてtrueを返す。

while(1) {
  // put your main code here, to run repeatedly:
  CANMessage msg;
  if(can.read(msg)) {
    // IDの確認
    printf("%d\n", msg.id);
    // データは`msg.data[0]〜[7]`に入っている。
    printf("%d\n", msg.data[0]);
  }
}
CANメッセージの送受信 全体像
src/main.cpp
#include <mbed.h>

// CAN1を使う場合
CAN can{PA_11, PA_12, (int)1e6};
// (通信速度を1MHzに設定)

// CAN2を使う場合はこっち
// CAN can{PB_12, PB_13, (int)1e6};

int main() {
  // put your setup code here, to run once:
  printf("setup\n");

  while(1) {
    // put your main code here, to run repeatedly:
    auto now = HighResClock::now();
    static auto pre = now;

    // CANメッセージの受信
    CANMessage msg;
    if(can.read(msg)) {
      // IDの確認
      printf("%d\n", msg.id);
      // データは`msg.data[0]〜[7]`に入っている。
      printf("%d\n", msg.data[0]);
    }

    // 10msごとに実行
    if(now - pre > 10ms) {

      // 送信データの作成
      uint32_t id = 0x200;
      uint8_t data[8] = {};
      // CANMessage構造体の作成
      CANMessage msg{id, data, sizeof(data)};
      // メッセージの送信
      can.write(msg);

      pre = now;
    }
  }
}
1111

CAN通信のトラブルシューティング

CANの初期化に失敗というエラーメッセージが出た場合

ピン指定が間違っている
CANトランシーバに電源が供給されていない

CANの初期化には成功するが通信できない場合

can.writeの返り値がfalseになっていると思う。
基本的には回路がおかしいので回路を再チェックする。5Vの給電、終端抵抗など。

1111

DJIモーターの制御

M3508を動かすためにC620とCAN通信を行う。[1][2]

src/C620.hppを作成。

src/C620.hpp
src/C620.hpp
#ifndef RCT_C620_HPP
#define RCT_C620_HPP
/// @file
/// @brief Provides the C620 class for controlling the motor driver for M3508.
/// @copyright Copyright (c) 2024 Yoshikawa Teru
/// @license This project is released under the MIT License.

#include <mbed.h>

#include <array>

/// @brief The C620 motor driver class for M3508.
struct C620 {
  static constexpr int max = 16384;

  void set_current(float current) {
    raw_current_ = max / 20.0 * current;
  }
  void set_raw_current(int16_t raw_current) {
    raw_current_ = raw_current;
  }
  uint16_t get_angle() const {
    return rx_.angle;
  }
  int16_t get_rpm() const {
    return rx_.rpm;
  }
  float get_actual_current() const {
    return 20.0 / max * rx_.actual_current;
  }
  int16_t get_actual_raw_current() const {
    return rx_.actual_current;
  }
  uint8_t get_temp() const {
    return rx_.temp;
  }
  int16_t get_raw_current() const {
    return raw_current_;
  }
  void parse_packet(const uint8_t data[8]) {
    rx_.angle = uint16_t(data[0] << 8 | data[1]);
    rx_.rpm = int16_t(data[2] << 8 | data[3]);
    rx_.ampere = int16_t(data[4] << 8 | data[5]);
    rx_.temp = data[6];
  }

 private:
  /// @brief The packet structure of the C620 motor driver.
  struct C620Packet {
    uint16_t angle;
    int16_t rpm;
    int16_t actual_current;
    uint8_t temp;
  } rx_ = {};
  int16_t raw_current_ = {};
};

/// @brief The C620 motor driver array for M3508.
struct C620Array {
  void parse_packet(const CANMessage& msg) {
    if(msg.format == CANStandard && msg.type == CANData && msg.len == 8 && 0x200 <= msg.id && msg.id <= 0x208) {
      arr_[msg.id - 0x201u].parse(msg.data);
    }
  }
  auto to_msgs() -> std::array<CANMessage, 2> const {
    uint8_t buf[16];
    for(int i = 0; i < 8; i++) {
      buf[i] = arr_[i].get_raw_current() >> 8;
      buf[i + 1] = arr_[i].get_raw_current() & 0xff;
    }
    return {CANMessage{0x200, buf}, CANMessage{0x1FF, buf + 8}};
  }
  auto& operator[](int index) {
    return arr_[index];
  }
  auto begin() {
    return std::begin(arr_);
  }
  auto end() {
    return std::end(arr_);
  }

 private:
  C620 arr_[8] = {};
};

#endif  /// RCT_C620_HPP

src/main.cppを作成。

src/main.cpp
#include <mbed.h>

#include "C620.hpp"

CAN can{PB_12, PB_13, (int)1e6};
DigitalIn button{BUTTON1};
C620Array c620{};

int main() {
  // put your setup code here, to run once:
  printf("setup\n");

  while(1) {
    // put your main code here, to run repeatedly:
    auto now = HighResClock::now();
    static auto pre = now;

    if(CANMessage msg; can.read(msg)) {
      c620.parse_packet(msg);
    }

    // 10msごとに実行↓
    if(now - pre > 10ms) {
      int16_t out = 0;
      if(!button) out = 6000;

      printf("% 7d ", out);
      for(auto& e: c620) {
        printf("% 7d ", c620.get_rpm());
      }

      for(auto& e: c620) {
        e.set_raw_current(out);
      }

      auto msgs = c620.to_msgs();
      if(!can.write(msgs[0]) || !can.write(msgs[1])) {
        printf("failed to write c620 msg ");
      }

      printf("\n");
      pre = now;
    }
  }
}

https://github.com/teruyamato0731/c620_pid

脚注
  1. https://www.robomaster.com/en-US/products/components/general/M3508?from=online-store&position=download#download ↩︎

  2. https://rm-static.djicdn.com/tem/17348/RoboMaster C620 Brushless DC Motor Speed Controller V1.01.pdf ↩︎

1111

PID制御

PID制御の詳しい解説はこちらを参照のこと。
https://zenn.dev/teruyamato0731/scraps/66339a544c0019

PID制御を行う

ここからダウンロードするか、下からコピペしてsrcの中にVelPid.hppを作成する。[1]

PID制御クラスの中身
src/VelPid.hpp
#ifndef VEL_PID_HPP
#define VEL_PID_HPP
/// @file
/// @brief Provides the VelPid class for velocity PID control.
/// @copyright Copyright (c) 2024 Yoshikawa Teru
/// @license This project is released under the MIT License, see [LICENSE](https://github.com/teruyamato0731/Chassis/blob/main/LICENSE).

#include <algorithm>
#include <chrono>
#include <cmath>

/// @brief Gains for a PID controller.
struct PidGain {
  float kp;
  float ki;
  float kd;
};

/// @brief Gains and limits for a PID controller.
struct PidParam {
  PidGain gain;
  float min = NAN;
  float max = NAN;
};

/// @brief Velocity Form PID controller.
template<class T = float>
class VelPid {
 public:
  /// @brief Constructor with the specified gains and limits.
  /// @param param The parameters for the PID controller.
  VelPid(const PidParam& param) : param_{param} {}

  /// @brief Calculates the output of the PID controller.
  T calc(const T target, const T actual, const std::chrono::duration<float>& dt) {
    return calc(target - actual, dt);
  }

  /// @brief Calculates the output of the PID controller.
  T calc(const T error, const std::chrono::duration<float>& dt) {
    using namespace std;
    const auto sec = dt.count();
    const auto prop = (error - pre_error_) / sec;
    const auto deriv = isnan(pre_prop_) ? 0 : (prop - pre_prop_) / sec;
    pre_error_ = error;
    pre_prop_ = prop;
    low_pass_deriv_ += deriv / 8;
    const auto du = param_.gain.kp * prop + param_.gain.ki * error + param_.gain.kd * low_pass_deriv_;
    output_ = clamp(output_ + du * sec, param_.min, param_.max);
    return output_;
  }

  /// @brief Resets the PID controller.
  void reset() {
    pre_error_ = 0.0;
    pre_prop_ = NAN;
    low_pass_deriv_ = 0.0;
    output_ = 0.0;
  }

  /// @brief Sets the parameters for the PID controller.
  void set_param(const PidParam& param) {
    param_ = param;
    reset();
  }

  /// @brief Sets the gains for the PID controller.
  void set_gain(const PidGain& gain) {
    param_.gain = gain;
    reset();
  }

  /// @brief Sets the limits for the PID controller.
  void set_limit(const float min, const float max) {
    param_.min = min;
    param_.max = max;
  }

 private:
  PidParam param_;
  T pre_error_ = 0.0;
  T pre_prop_ = NAN;
  T low_pass_deriv_ = 0.0;
  T output_ = 0.0;
};

#endif  // VEL_PID_HPP
#include <mbed.h>

#include "C620.hpp"
#include "VelPid.hpp"

CAN can{PB_12, PB_13, (int)1e6};

// Kp = 0.5, Ki = 0.2, Kd = 0.05
// min  = -0.7, max = 0.7
VelPid pid{{{0.5, 0.2, 0.05}, -0.7, 0.7}};
C620Array c620;

constexpr float target_rpm = 2000;

int main() {
  // put your setup code here, to run once:
  printf("setup\n");

  while(1) {
    // put your main code here, to run repeatedly:
    auto now = HighResClock::now();
    static auto pre = now;
    auto dt = now - pre;

    if(CANMessage msg; can.read(msg)) {
      c620.parse_packet(msg);
    }

    // 10msごとに実行↓
    if(dt > 10ms) {
      auto actual_rpm = c620[0].get_rpm();
      const float out = pid.calc(target_rpm, actual_rpm, dt);
      c620[0].set_current(out);
      printf("output: %d ", c620[0].get_raw_current());

     auto msgs = c620.to_msgs();
      if(!can.write(msgs[0]) || !can.write(msgs[1])) {
        printf("failed to write c620 msg ");
      }

      printf("\n");
      pre = now;
    }
  }
}
脚注
  1. https://gist.github.com/teruyamato0731/176ab63cc9cf74cd70c55bff12316959 ↩︎

1111

三輪オムニの制御

移動速度\boldsymbol{v}:=(v_x, v_y)が与えられているときの、モーター1,2,3の目標角速度\omega_1, \omega_2, \omega_3を考える。

モーター1,2,3の回転方向を、x方向を基準としたy方向への傾き\theta_iとして表す。

図より(\theta_1, \theta_2, \theta_3) = (0, \frac{2\pi}{3}, -\frac{2\pi}{3})

TODO: 図に表す

初めに\boldsymbol{v}:=(v_x,v_y)を極座標(r,\phi)で示す。

\begin{aligned} r &= \sqrt{{v_x}^2 + {v_y}^2} \\ \phi &= \arctan\frac{v_y}{v_x} \\ \end{aligned}

C/C++で計算すると以下のようになる。[1][2]

r = hypot(vec.x, vec.y);
phi = atan2(vec.y, vec.x);

さらにこの値を使ってモーターへの出力を計算する
ただしここで\proptoは比例関係を表す。

\begin{aligned} \omega_1 &\propto r\cos(\phi + \theta_1) \\ \omega_2 &\propto r\cos(\phi + \theta_2) \\ \omega_3 &\propto r\cos(\phi + \theta_3) \\ \end{aligned}

C/C++で計算すると以下のようになる。

float output[3];
for(int i = 0; i < 3; ++i) {
  output[i] = r * cos(phi + theta[i]);
}

さらに回転速度\omegaも与えられている場合、速度ベクトルは\boldsymbol{v}:=(v_x, v_y, \omega)であり、モーターへの出力は

\begin{aligned} \omega_1 &\propto r\cos(\phi + \theta_1) + k\omega \\ \omega_2 &\propto r\cos(\phi + \theta_2) + k\omega \\ \omega_3 &\propto r\cos(\phi + \theta_3) + k\omega \\ \end{aligned}

ただしここでkは回転速度の比例定数
C/C++で計算すると以下のようになる。

float output[3];
for(int i = 0; i < 3; ++i) {
  output[i] = r * cos(phi + theta[i]) + k * omega;
}

またロボットのヨー角\theta_\mathrm{yaw}をオフセットとして考える場合、

\begin{aligned} \omega_1 &\propto r\cos(\phi + \theta_\mathrm{yaw} + \theta_1) + k\omega \\ \omega_2 &\propto r\cos(\phi + \theta_\mathrm{yaw} + \theta_2) + k\omega \\ \omega_3 &\propto r\cos(\phi + \theta_\mathrm{yaw} + \theta_3) + k\omega \\ \end{aligned}
float output[3];
for(int i = 0; i < 3; ++i) {
  output[i] = r * cos(phi + yaw + theta[i]) + k * omega;
}

なお以下定数を得ることで比例定数を決定できる。
R:オムニ中心からモータまでの距離
r_w:オムニホイールの半径

\begin{aligned} r_w\omega_1 &= r\cos(\phi + \theta_\mathrm{yaw} + \theta_1) + R\omega \\ r_w\omega_2 &= r\cos(\phi + \theta_\mathrm{yaw} + \theta_2) + R\omega \\ r_w\omega_3 &= r\cos(\phi + \theta_\mathrm{yaw} + \theta_3) + R\omega \\ \end{aligned}

四輪オムニの制御

同様にして速度ベクトル\boldsymbol{v}:=(v_x, v_y, \omega)、オフセット角\theta_\mathrm{yaw}が与えられているときの、モーター1〜4の目標角速度は\omega_1, \omega_2, \omega_3, \omega_4

図より(\theta_1, \theta_2, \theta_3, \theta_4) = (\frac{3\pi}{4}, \frac{5\pi}{4}, \frac{7\pi}{4}, \frac{\pi}{4})

TODO: 図に表す

\begin{aligned} r_w\omega_1 &= r\cos(\phi + \theta_\mathrm{yaw} + \theta_1) + R\omega \\ r_w\omega_2 &= r\cos(\phi + \theta_\mathrm{yaw} + \theta_2) + R\omega \\ r_w\omega_3 &= r\cos(\phi + \theta_\mathrm{yaw} + \theta_3) + R\omega \\ r_w\omega_4 &= r\cos(\phi + \theta_\mathrm{yaw} + \theta_4) + R\omega \\ \end{aligned}
float output[4];
for(int i = 0; i < 4; ++i) {
  output[i] = r * cos(phi + yaw + theta[i]) + k * omega;
}
脚注
  1. https://cpprefjp.github.io/reference/cmath/atan2.html ↩︎

  2. https://cpprefjp.github.io/reference/cmath/hypot.html ↩︎

1111

三角関数を使用しない方法

オフセット角を与えないならば、(v_x,v_y,\omega)のみで出力を表現できる。

R:オムニ中心からモータまでの距離
r_w:オムニホイールの半径

三輪オムニ

\begin{aligned} r_w\omega_1 &= \hphantom{-\frac{1}{2}}x \hphantom{ {}+\frac{\sqrt{3}}{2}y }+ R\omega \\ r_w\omega_2 &= -\frac{1}{2}x + \frac{\sqrt{3}}{2}y + R\omega \\ r_w\omega_3 &= -\frac{1}{2}x - \frac{\sqrt{3}}{2}y + R\omega \\ \end{aligned}

これを行列で表現すると

\begin{aligned} r_w \begin{pmatrix} \omega_1 \\ \omega_2 \\ \omega_3 \\ \end{pmatrix} &= \begin{pmatrix} 1 & 0 & R \\ -\frac{1}{2} & \frac{\sqrt{3}}{2} & R \\ -\frac{1}{2} & -\frac{\sqrt{3}}{2} & R \\ \end{pmatrix} \begin{pmatrix} v_x \\ v_y \\ \omega \\ \end{pmatrix} \end{aligned}

四輪オムニ

\begin{aligned} r_w\omega_1 &= -\frac{\sqrt{2}}{2}x + \frac{\sqrt{2}}{2}y + R\omega \\ r_w\omega_2 &= -\frac{\sqrt{2}}{2}x - \frac{\sqrt{2}}{2}y + R\omega \\ r_w\omega_3 &= \hphantom{-{}}\frac{\sqrt{2}}{2}x - \frac{\sqrt{2}}{2}y + R\omega \\ r_w\omega_4 &= \hphantom{-{}}\frac{\sqrt{2}}{2}x + \frac{\sqrt{2}}{2}y + R\omega \\ \end{aligned}

これを行列で表現すると

\begin{aligned} r_w \begin{pmatrix} \omega_1 \\ \omega_2 \\ \omega_3 \\ \omega_4 \\ \end{pmatrix} &= \begin{pmatrix} -\frac{\sqrt{2}}{2} & \frac{\sqrt{2}}{2} & R \\ -\frac{\sqrt{2}}{2} & -\frac{\sqrt{3}}{2} & R \\ \frac{\sqrt{2}}{2} & -\frac{\sqrt{3}}{2} & R \\ \frac{\sqrt{2}}{2} & \frac{\sqrt{2}}{2} & R \\ \end{pmatrix} \begin{pmatrix} v_x \\ v_y \\ \omega \\ \end{pmatrix} \end{aligned}
1111

モーター出力の決定

モーターの電圧制御・電流制御、どちらの場合でも入力が角速度に比例すると考えて実用上そこまで問題はない。

速度の精度が必要な場合はモーターの角速度に対してPID制御を行えばよい。

1111

オムニのPID制御(カスケード制御)

目標速度ベクトル\boldsymbol{v}_\mathrm{ref} = (v_x, v_y, \omega)
実際の速度\boldsymbol{v}_\mathrm{act}
目標角速度\boldsymbol{\omega}_\mathrm{ref}
実際の角速度\boldsymbol{\omega}_\mathrm{act}

\begin{aligned} \boldsymbol{v}_\mathrm{out} &= \mathrm{PID}(\boldsymbol{v}_\mathrm{ref}, \boldsymbol{v}_\mathrm{act}) \\ \boldsymbol{\omega}_\mathrm{ref}|_i &= \frac{|\boldsymbol{v}_\mathrm{out}|}{r_w}\cos(\arg[\boldsymbol{v}_\mathrm{out} ] + \theta_\mathrm{yaw} + \theta_i) + \frac{R}{r_w}\omega \\ \boldsymbol{\omega}_\mathrm{out} &= \mathrm{PID}(\boldsymbol{\omega}_\mathrm{ref}, \boldsymbol{\omega}_\mathrm{act}) \end{aligned}
auto vec = pid1.calc(vec_ref, velocity, dt);
float r = hypot(vec.x, vec.y);
float phi = atan2(vec.y, vec.x);
float omega_ref[3];
for(int i = 0; i < 3; ++i) {
  omega_ref[i] = r * cos(phi + yaw + theta[i]) + k * omega;
}
float output[3];
for(int i = 0; i < 3; ++i) {
    output[i] = pid2[i].calc(omega_ref[i], omega_actual[i], dt);
}
1111

ロータリーエンコーダの使い方

うちのロボ部でよく使用するAMT102について解説する。

AMT102の解説

AMT102はインクリメンタル式のロータリーエンコーダである。ロータリーエンコーダは回転する軸に取り付けて、その軸の回転数や回転速度を計測するものである。略称:エンコーダ・ロリコン。エンコーダにはアブソルート式とインクリメンタル式があるが、AMT102は安くて簡単に使えるインクリメンタル式である。
一般的なインクリメンタル式のエンコーダにはA層・B層の2枚の金属板が入っており、その2枚が位相を90°ずらして配置されている。[1]軸が回転するごとにA・Bの金属板が回転し、金属板に入ったスリットを光が通り抜けることで、フォトトランジスタからパルスが発生する。A層・B層それぞれのパルスから現在の位相を読み取ることで、前回読み取った際の位相からどれだけ移動したかを読み取ることができる。[2]
エンコーダには分解能(resolution)というものが存在する。分解能とはセンサで取得できる情報の細かさのことである。エンコーダの分解能は取得できる角変位の細かさであり、軸を1回転させた場合に出力されるパルス数のことである。[3]A層・B層それぞれに立ち上がりと立ち下がりがあるので、分解能の4倍の精度で角度を取得できる。[4]ただし分解能を上げると角速度があがったときにパルスの読み飛ばしが発生するため、分解能は軸の角速度に応じて設定する必要がある。AMT102の場合は裏のスイッチを切り替えることで分解能を変更できる。[5]
Z, X, あるいはIndexと書かれたピンはエンコーダが1回転したときに1度だけパルスを発生させるピンである。インクリメンタル式のエンコーダには積算誤差が発生するが、インデックスを使用することで積算誤差を打ち消すことができる。

AMT102にはA層、B層、X(インデックス)、GND、5Vのピンがある。それぞれをよしなにマイコンに配線する。

エンコーダの読み取り

いくつかの手法があるのでそれぞれの方法と利点を解説する。

手法0. 回転方向が不要で速度のみを計測する

A層またはB層あるいはその両方のピンにピン割り込みを設定する。
割り込みのたびにカウンタをインクリメントする。

コード例
#include <mbed.h>

InterruptIn a_phase{D4};
InterruptIn b_phase{D5};

volatile int32_t omega = 0;
int64_t pos = 0;
void increment() {
    omega = omega + 1;
}

int main() {
    a_phase.rise(increment);
    a_phase.fall(increment);
    b_phase.rise(increment);
    b_phase.fall(increment);
    while(1) {
        auto tmp = omega;
        omega = 0;
        pos + = tmp;
        printf("pos:%d vel:%d\n", pos, tmp);
        ThisThread::sleep_for(1s);
    }
}

手法1. mainループでreadする

コード例
#include <mbed.h>

uint8_t status = 0;
int64_t count = 0;

int get_increment(uint8_t status) {
    switch(status) {
        case 1: case 7: case 8: case 14:
            return 1;
        case 2: case 4: case 11: case 13:
            return -1;
        default:
            return 0;
    }
}

int main() {
    while(1) {
        status = a_phase << 3 | b_phase << 2 | status >> 2;
        count += get_increment(status);
        printf("pos:%d", pos);
    }
}

手法2. 割り込みでインクリメントする

手法0や手法1と同様の手法でも読み取れるが、より効率の良い手法を示す。
ただし以下例ではA層のみがピン割り込みの設定である。B層にもピン割り込みを設定する場合は、同様にコールバック関数を作って渡せばよい。

FallとRiseを区別する手法
volatile int64_t counter = 0;
void a_rise() {
    if(b_phase) {
        counter += 1;
    } else {
        counter -= 1;
    }
}
void a_fall() {
    if(b_phase) {
        counter -= 1;
    } else {
        counter += 1;
    }
}

int main() {
    a_phase.rise(a_rise);
    a_phase.fall(a_fall);
    while(1) {
        printf("%d\n", counter);
    }
}

補足:Lookup テーブルについて

Lookupテーブルを作成すればif文を細かく書かずに、ピンの状態を配列の添字に与えるだけで出力が得られる。

手法3. マイコン側の機能で読み取る

STMマイコン等にはエンコーダ読み取りの機能があるため、Cube等を使用すればマイコン側でエンコーダを読み取ることができる。

脚注
  1. https://www.tamagawa-seiki.co.jp/products/rotaryencoder/rotaryencoder-about.html ↩︎

  2. https://maruchan-note.com/2022/07/01/20220701/ ↩︎

  3. https://faq.fa.omron.co.jp/tech/s/article/faq00951 ↩︎

  4. https://primemotion.com/encoder/ ↩︎

  5. https://www.cuidevices.com/product/resource/amt10.pdf ↩︎

1111

足回りのオドメトリ

オドメトリとは自己位置推定の手法の1つで、車輪の回転角度からそれぞれの移動量を求め、その和を積算することによりロボットの位置を推定する手法の総称である。
オドメトリはタイヤが滑らないことを仮定するが、タイヤは滑るものなので移動するごとに少しずつ誤差が蓄積する。よってオドメトリには誤差が含まれることを前提として、制御する必要がある。
誤差への対応は後述する。

はじめにエンコーダ3つの場合を解説し、エンコーダ4つの場合についても式を示す。
制御周期ごとに、エンコーダによって各車輪の移動量\Delta e_iが得られると仮定する。
ロボット中心の座標を(x, y)、反時計回りの姿勢角を\thetaとして、ステップkでの自己位置\bm{x}_k = [x, y, \theta]^\mathsf{T}を推定する。
なお後に示す単位変換により、移動量の単位は吸収されるため、あまり考慮する必要はない(すなわち、パルス数をそのまま用いてよい)。

以上をモデル図に示す。


オドメトリのモデル図

車輪角度のみに注目した図も示す。

エンコーダ3つのオドメトリ

各車輪の初期角度に、現在の姿勢角\thetaを足すことで、車輪の向きを得る。

\begin{aligned} \phi_0 &= 0 + \theta \\ \phi_1 &= \frac{2\pi}{3} + \theta \\ \phi_2 &= \frac{4\pi}{3} + \theta \\ \end{aligned}

各車輪の移動量を並進成分と回転成分に分解する。

\begin{aligned} \Delta x &= \sum_{i=0}^2 \Delta e_i\cos\phi_i \\ &= \Delta e_0\cos\phi_0 + \Delta e_1\cos\phi_1 + \Delta e_2\cos\phi_2 \\ \Delta y &= \sum_{i=0}^2 \Delta e_i\sin\phi_i \\ &= \Delta e_0\sin\phi_0 + \Delta e_1\sin\phi_1 + \Delta e_2\sin\phi_2 \\ \Delta\theta &= \sum_{i=0}^2 \Delta e_i \\ &= \Delta e_0 + \Delta e_1 + \Delta e_2 \\ \end{aligned}

エンコーダ4つのオドメトリ

エンコーダ4つの場合も同様に、移動量の和を積算することで推定できる。

\begin{aligned} \Delta x &= \sum_{i=0}^3 \Delta e_i\cos\phi_i \\ \Delta y &= \sum_{i=0}^3 \Delta e_i\sin\phi_i \\ \Delta\theta &= \sum_{i=0}^3 \Delta e_i \\ \end{aligned}

数式の一般化

エンコーダ数をNとおけば一般化できる。

\begin{aligned} \phi_i &= \theta + \frac{2\pi}{N}i \quad\text{for}\ i = 0,\dots,N-1 \\ \end{aligned}
\begin{aligned} \Delta x &= \sum_{i=0}^{N-1} \Delta e_i\cos\phi_i \\ \Delta y &= \sum_{i=0}^{N-1} \Delta e_i\sin\phi_i \\ \Delta\theta &= \sum_{i=0}^{N-1} \Delta e_i \\ \end{aligned}

単位変換について

移動量\Delta\bm{x}は単位変換を考慮していない。単位変換用の定数Kを用意して、積算するときに変換することで対応する。定数Kは実機実験により求めるのが一番良い。
\bm{x}_k = \bm{x}_{k-1} + K\Delta\bm{x}より、前回時刻の値に移動量を足し合わせることで、現在の自己位置を推定する。

\begin{aligned} x_k &= x_{k-1} + K_0\Delta x \\ y_k &= y_{k-1} + K_1\Delta y \\ \theta_k &= \theta_{k-1} + K_2\Delta\theta \\ \end{aligned}

プログラムを書く

C++によるコード例を示す。

odometryの計算
#include <array>
#include <cmath>

/// エンコーダ数
static constexpr int N = 3;

/// @brief エンコーダ変位の変化量より、自己位置の変化量を計算する。
/// @param enc エンコーダ変位の変化量
/// @param offset_rad 姿勢角[rad]
/// @return 移動量[dx, dy, dθ]
std::array<float, N> odometry(const std::array<float, N>& enc, float offset_rad) {
  constexpr float pi_n = 2 * M_PI / N;
  std::array<float, N> dx = {};
  for (int i = 0; i < N; ++i) {
    dx[0] += enc[i] * std::sin(offset_rad + i * pi_n);
    dx[1] += enc[i] * std::cos(offset_rad + i * pi_n);
    dx[2] += enc[i];
  }
  return dx;
}
// [x, y, θ] を0で初期化
float x_est[3] = {};
auto enc = /* 値の取得 */;
auto dx = odometry(enc, x_est[2]);
for (int i = 0; i < 3; ++i) {
  x_est[i] += dx[i];
}

積算誤差について

移動距離が伸びるほど、積算誤差も大きくなっていく。一定の周期で、壁にリミットスイッチを当てることにより、推定値の誤差をリセットする。

このスクラップは2024/06/18にクローズされました