目次 [ Contents ]
前回、ロータリーエンコーダの効率的な読み方について書きましたが、あくまで「クリック付き」タイプについてのものでした。
今回は「クリックなし」のエンコーダを使って、分解能を4倍に引き上げた読み取りに挑戦します。勿論、スケッチ上だけの処理です。
今回紹介するものは「完璧」ではないので、それを求めるなら、「アブソリュート型」といった高級で精度の高いパーツや、回路を使用することをオススメします。ただ、自分的には、これだけ出来れば充分なレベルかな、と。
この記事の更なる改善版スケッチは Part 3へ (2017/10/22)
回路図
「クリックなし」タイプのロータリーエンコーダ。あと、なくても構いませんが、確認用にLEDを1つ。いつも通り、エンコーダAB相はArduino内部プルアップ接続です。
ロータリーエンコーダ
一般的にロータリーエンコーダは、4つのずれた接点が並んでいて、今の組み合わせと、前回の組み合わせを照合することで、「進んだ」、「戻った」の判断をします。そして、「クリックあり」のタイプは、4つの組み合わせを1クリックとして認識させています。例えば、20クリックのエンコーダなら、1回転で80回接点が切り替わっているわけです。
それを全部拾えたとしたら、エンコーダをもっと細かくコントロールできるはず。つまり、「分解能が上がる」ことになります。これがやることの今回の目的です。
「クリックなし」タイプのロータリーエンコーダは普通に手に入ります。
ただし、これらは1回転あたりのクリック数が低いものが多く、そうでないものを探すのは大変です。そこで、クリック付きで多いものを選び、物理的に「クリック」を外してやる方法があります。
エンコーダの改造
改造は自己責任で行ってください。
この分解能を上げる方法は、この「クリック無しへの改造」の記事を読んだのがきっかけです。
クリックの仕組みは中の回転台にデコボコと突起が噛み合って発生していますが、その突起と絡まないようにすればクリック「付き」が「なし」になります。
自分が見つけたもので一番クリック数のあるエンコーダはこちらですが、今回の説明では、手元で遊んでいたこのエンコーダを使います。
このエンコーダは1回転24クリックです。つまり、全部拾えれば1回転で96の分解能になります。
多分、エンコーダごとに細かい仕組みが違うと思うので、あくまで参考です。
スケッチ
スケッチにミスがあったので修正し(Ver1.1)、多少読み取りが改善しました(2017/10/16 追記)
更なる改善版は Part 3へ(2017/10/22 追記)
このスケッチは“外部割り込み”ではなく、「MsTimer2」ライブラリを使って、“タイマー割り込み”動作をしています。IDEにインストールされていなければ「ライブラリを管理」から入手してください。
このプロジェクトファイルをダウンロードするか、
下記のコードをコピペして使ってください。
うまくコンパイル出来ない場合、こちらを読んでください。
// Rotary Encoder Reading with watch dog timer and Dual Gauge // HighResEnc Version 1.1 by jumbler #include <MsTimer2.h> #define TIM MsTimer2 //pins #define ENCA 2 #define ENCB 3 #define LED 4 //encoder constants #define ENC_JUDGE 30 #define ECUR B00000011 // enc current position #define EPRE B00001100 // enc previous position #define EHOM B00110000 // enc home position #define ETRG B01000000 // enc has been triggered #define ECHG B10000000 // encoder count has been charged const byte enc_pins[2] = {ENCA, ENCB}; volatile byte enc_status; volatile short enc_count = 0; void setup() { pinMode(LED, OUTPUT); Serial.begin(38400); for (byte i = 0 ; i < 2 ; i++) { pinMode(enc_pins[i], INPUT_PULLUP); digitalWrite(LED, 1 - i); delay(1000); } TIM::set(1, ENC_GAUGE); TIM::start(); enc_status = ENC_PIN_READ() << SBIT(EHOM); } void loop() { static int val; static unsigned long time_led; if (SBVAL(enc_status, ECHG)) { digitalWrite(LED, HIGH); val = ENC_COUNT(val); Serial.println(val); time_led = millis() + 10; } else { if (millis() > time_led) digitalWrite(LED, LOW); } } short ENC_COUNT(int val) { static int enc_old; if (enc_old != enc_count) { val += enc_count - enc_old; enc_old = enc_count; enc_status = enc_status & ~ECHG; } return val; } byte SBIT(byte layer) { for (byte i = 0 ; i < 8; i++) if ((layer >> i)&B00000001) return i; } byte SBVAL(byte val, byte layer) { byte tmp = (val & layer) >> SBIT(layer); return tmp; } byte ENC_PIN_READ() { // Read encoder pins status byte enc_cur = (digitalRead(ENCB) << 1) + digitalRead(ENCA); // Modify position order if (enc_cur < 2) enc_cur = 1 + (enc_cur * -1); if (!SBVAL(enc_status, ETRG)) { if (SBVAL(enc_status, EHOM) != enc_cur) enc_status = enc_status | ETRG; } // apply update to enc_status enc_status = (enc_status & B11110000) + ((enc_status & ECUR) << 2) + enc_cur; return enc_cur; } void ENC_GAUGE() { static unsigned short gauge[2]; byte curr = ENC_PIN_READ(); // if encoder change has been triggerd if (SBVAL(enc_status, ETRG)) { byte prev, dist; dist = SBVAL(enc_status, EHOM); for (byte i = 0 ; i < (ENC_JUDGE * 1.5) ; i++) { curr = ENC_PIN_READ(); prev = SBVAL(enc_status, EPRE); // each gauge for "moved" or "not moved" bool bias = (curr != dist) ? 1 : 0; gauge [bias]++; int goal = gauge[1] - gauge[0]; if (abs(gauge[1] - gauge[0]) > ENC_JUDGE) { // encoder moved! if (goal > 0) { // increase or decrease bool dir = ((curr - dist) > 0) ? 1 : 0; if (curr == 0 && dist == 3) dir = 1; else if (curr == 3 && dist == 0) dir = 0; // add count by the direction if (dir) enc_count++; else enc_count--; // update home position enc_status = (enc_status & ~EHOM) + (curr << SBIT(EHOM)); enc_status = enc_status | ECHG; } for (byte ii = 0 ; ii < 2 ; ii++) gauge[ii] = 0; enc_status = enc_status & ~ETRG; break; } } } }
ダウンロードのプロジェクトファイルの方が、タブでセクション分けしているので、自分のプロジェクトに合ったコードを付け足していきやすいと思います。
使い方は簡単で、ENC_COUNT()関数に任意の変数を投げれば、エンコーダの変化を反映した値が帰ってきます。
static int val; val = ENC_COUNT(val); Serial.println(val);
スケッチの“void Loop”内に書かれているサンプルでは、変数valをシリアルモニタしているので、数値が確認できると思います。
また、シリアルプロットで見ると、視覚的に動作を確認できます。(Ctrl+Shift+M)
早い動きでは、やはり取りこぼしがありますが、数値が明後日の方向へ飛んでしまう極端な振る舞いはしないと思うので、そこそこの速さなら、かなり安定した操作が出来ると思います。
現段階では他のルーチンと合わせてプログラムを組んでいないので、大きなスケッチになってきた時、どれだけ対応できるかは保障できません。なので、試した方は感想や状況なんかをコメントに残して置いていただけると嬉しいです。
最後にどういう考え方で書いたか解説を書いておきます。気になる方はどうぞ。
解説
まずはチャタリングがどれだけ起きるのか、実際に確認してみる必要があります。
チャタリングを見る
外部割込みを使って、ピンの変化を記録、表示します。外部割込み中にシリアルプリントしてしまうと、表示にかかる遅延で正確に見れないと思うので、「ピンの変化」と「経過時間」を大きな配列で用意しておき、落ち着いたらまとめて表示するようにしています(ちょっと配列の入れ方が間違っているので、最初と最後が正確ではありません)。
// watch chattering #define ENCA 2 #define ENCB 3 #define LED 4 const byte enc_pins[2] = {ENCA, ENCB}; void setup() { pinMode(LED, OUTPUT); Serial.begin(38400); for (byte i = 0 ; i < 2 ; i++) { pinMode(enc_pins[i], INPUT_PULLUP); attachInterrupt(i, ENC_HIT, CHANGE); digitalWrite(LED, 1 - i); delay(1000); } } #define MEM 200 volatile unsigned long time_enc_read[MEM]; volatile byte enc_stat[MEM]; volatile unsigned int read_count; void loop() { if (read_count > 0 && (micros() - time_enc_read[read_count]) > 1000000) { for (byte i = 0 ; i <= read_count ; i++) { if (i < 100) Serial.print("0"); if (i < 10) Serial.print("0"); Serial.print(i); Serial.print(" "); Serial.print((char)('A' + enc_stat[i])); Serial.print(" "); Serial.print(time_enc_read[i] - time_enc_read[max(0, i - 1)]); Serial.println(); } Serial.println(); read_count = 0; } } byte ENC_PIN_READ() { // Read encoder pins byte enc_cur = (digitalRead(ENCB) << 1) + digitalRead(ENCA); // Modify position order if (enc_cur < 2) enc_cur = 1 + (enc_cur * -1); return enc_cur; } void ENC_HIT() { digitalWrite(LED, HIGH); enc_stat[read_count] = ENC_PIN_READ(); time_enc_read[read_count] = micros(); read_count++; digitalWrite(LED, LOW); }
シリアルモニタには、チャタリングで安定していないモノも含めた変化の流れが帰ってきます。アルファベット「A~D」が2つのピンのパターンなので、1目盛分の動きでかなり行ったり来たりしているのが分かります。
その回数はバラつきがあって、素直に一回で変わる時もあれば、100回以上繰り返している時もあります。早く回すと、どこが切り替わりのポイントか分かりません。
方法論
上記の検証から、外部割込みをしたところで、正確な変更を拾える保障はありません。
そこで、いっそのことタイマー割り込みで頻繁に読み取り更新をかけ、おなじみの“ゲージ判定”で、位置が変わったのかどうかを判断するような仕組みにします。
- 確定している位置“Home” Position
- 最新の読み取り位置“Current” Position
これらを比較して、動きの判断をします。
“ゲージ判定”を使った、スイッチ読み取りライブラリを作ってみました。(2017.6.26 追記)
HomeとCurrentが違う位置を示せば、「エンコーダが動き始めたかもしれない」ことを判断する“Trigger”が入り、その合図きっかけで、集中的にABピンの読み取りを繰り返します。
動いたか、動いていないかのgaugeを2つ用意し、天秤にかけ、先に既定値(ENC_JUDGE)に到達したほうを採用。動いていれば、“Home”を更新してカウンターを増減、そうでなければ破棄します。どちらに転んでも、判定が終われば“gauge”と“Trigger”はリセットし、次の変化を待つ。
これの繰り返しでエンコーダを読んでいきます。
関数など
エンコーダのパターンは2bitで表現できるので、1byteのグローバル変数「enc_status」を作り、エンコーダのピン状態はここにまとめて織り込みます。各ビットの配置は以下の通り。
#define ECUR B00000011 // enc current position #define EPRE B00001100 // enc previous position #define EHOM B00110000 // enc home position #define ETRG B01000000 // enc has been triggered #define ECHG B10000000 // encoder count has been charged
これらはビット演算子で簡単に書き換えられるようにします。まず、そのための関数を作ります。
// 何ビット目かを判定し返す byte SBIT(byte layer) { for (byte i = 0 ; i < 8; i++) if ((layer >> i)&B00000001) return i; } // 指定したビットだけにマスクし、最小ビットへ右詰した数値を返す byte SBVAL(byte val, byte layer) { byte tmp = (val & layer) >> SBIT(layer); return tmp; }
次に、ピン状態を読む関数を作ります。
byte ENC_PIN_READ() { // ピンの状態を2bitで獲得 byte enc_cur = (digitalRead(ENCB) << 1) + digitalRead(ENCA); // 10進数にすると、順番が1023になるので、それを0123に強制補正 if (enc_cur < 2) enc_cur = 1 + (enc_cur * -1); // Triggerが入っていない時にcurrとhomeが違えばTrigger ON! if (!SBVAL(enc_status, ETRG)) { if (SBVAL(enc_status, EHOM) != enc_cur) enc_status = enc_status | ETRG; } // 最新のピン状態をenc_statusに格納する enc_status = (enc_status & B11110000) + ((enc_status & ECUR) << 2) + enc_cur; return enc_cur; }
タイマー割り込みさせるのはENC_GAUGE()関数です。ここで繰り返し読み込みの判定、カウンターの増減、諸々のリセットをしています。
void ENC_GAUGE() { static unsigned short gauge[2]; // 一回ピン状態を確認に行く byte curr = ENC_PIN_READ(); // triggerが入っていれば「連続読み取り」実行 if (SBVAL(enc_status, ETRG)) { byte prev, dist; dist = SBVAL(enc_status, EHOM); // ピン状態を繰り返し読みに行く(ENC_JUDGEの1.5倍分) for (byte i = 0 ; i < (ENC_JUDGE * 1.5) ; i++) { curr = ENC_PIN_READ(); prev = SBVAL(enc_status, EPRE); // HomeとCurrが違えば「移動」に1票、違えば「残留」に1票 bool bias = (curr != dist) ? 1 : 0; gauge [bias]++; int goal = gauge[1] - gauge[0]; //「移動」と「残留」にENC_JUDGE分の差がついたら確定 if (abs(gauge[1] - gauge[0]) > ENC_JUDGE) { // 「移動」確定 if (goal > 0) { // CurrとHomeの位置関係から増加したか、減少したか判断 bool dir = ((curr - dist) > 0) ? 1 : 0; if (curr == 0 && dist == 3) dir = 1; else if (curr == 3 && dist == 0) dir = 0; // グローバル変数enc_countに結果を反映 if (dir) enc_count++; else enc_count--; // homeの位置状態をCurrに合わせて更新、enc_countが変更されたことをON enc_status = (enc_status & ~EHOM) + (curr << SBIT(EHOM)); enc_status = enc_status | ECHG; } // gauge、Triggerをリセット for (byte ii = 0 ; ii < 2 ; ii++) gauge[ii] = 0; enc_status = enc_status & ~ETRG; break; // 繰り返し読む作業は終わりにしてFor文を抜ける } } } }
変更されたカウントはすぐに反映するのではなく、ENC_COUNT()のカウンターで管理させることで、数値のやりとりにすれ違いを出させなくし、また、色んな変数へ柔軟に代入できるようになります。
short ENC_COUNT(int val) { // 前回分のカウンターメモリー static int enc_old; // 前回からカウンターが更新されていれば実行 if (enc_old != enc_count) { // 投げられた変数に前回と今回のカウンター差分を足す val += enc_count - enc_old; // 前回分を更新 enc_old = enc_count; // チャージ合図をリセット enc_status = enc_status & ~ECHG; } return val; // 更新された変数を返す }
調整
今回のスケッチはArduinoUno用に書きましたが、使うマシンによって処理スピードが変わってくるので、反応が変わってくるかと思います。その場合、いくつか定数をいじることで改善するかもしれません。
- ENC_JUDGE
- TIM::set(1, ENC_GAUGE);
ENC_JUDGEはTriggerON時の、ピン読み取りの繰り返し回数と既定値に絡んでいます。ここを変更すると切り捨て度合いが変わってくるので、うまくいかない場合はいじるといいかもしれません。
また、タイマー割り込みは1msに設定されています。これ以下には出来ませんが、反応が早過ぎるときは、逆に落とすとうまく作用するかもしれません。
こうやって一行ずつ解説を書くと、試行錯誤していく中で意味が無くなってしまった部分もあったりするなあ、と。前回分とか、チャージのビットとか。まあ、そこら辺は気になれば消して使ってください。
参考リンク