IoTプラレールを作ってみました（その４：IoTサイクルメータの作成）


# はじめに

こんにちは。(株) 日立製作所の [Lumada Data Science Lab.](https://www.hitachi.co.jp/New/cnews/month/2020/03/0330d.html) の松本茂紀です。

IoTプラレールを作ってみましたシリーズの第４弾です。

今回はフィットネスバイクをハックしてオリジナルのIoTサイクルメータを作ろうの後編です。


> その他の記事は以下から参照できますのでよろしければどうぞ。
>
> [IoTプラレールを作ってみました（その1：IoT電池の作成 前編）](https://qiita.com/shi-matsumoto/items/368ba2002a9c0410e3ed)
>
> [IoTプラレールを作ってみました（その2：IoT電池の作成 後編）](https://qiita.com/shi-matsumoto/items/35b3f7b8e53ac47f0aa4)
>
> [IoTプラレールを作ってみました（その3：IoTサイクルメータを作ろう 前編）](https://qiita.com/shi-matsumoto/items/0d9fad8fae00abe087e4)
>
> [IoTプラレールを作ってみました（その5：プラレールとフィットネスバイクを電波でつなごう 前編）](https://qiita.com/shi-matsumoto/items/7c3584f0d3ec1f380660)
>
> [IoTプラレールを作ってみました（その6：プラレールとフィットネスバイクを電波でつなごう 後編）](https://qiita.com/shi-matsumoto/items/8931aefaf8edb4080bdb)





# ケイデンスを測ろう

自転車を漕ぐ際の運動強度として、ケイデンス（ペダルの回転速度）が一つの目安になってきます。一般的には１分間あたりの回転数（rpm）が90前後あると程よい有酸素運動になると言われています。そこで、ケイデンスを計算し、最終的にはプラレールを動かす速さの指標として活用しようと思います。

 デバイスから取得できる生のデータは、右足のペダルが一番下にきた際のON状態と、それ以外のOFF状態を検出した電気信号です。この電気信号を意味のある情報に変換し、分析に活用するのもデータサイエンティストには重要です。ここで、電気信号のONとOFFの時系列データからケイデンスを計算する方法について検討します。

 素直に考えると、１分間にペダルが１回転した回数を計算すれば良いことになります。しかし、１分間待たないと回転数が計算されないということは、プラレールが１分経たないと動き出さない、または止まらないことになります。ここまでタイムラグがあるとペダルを漕いでプラレールを動かしている感じがないですね。もっと短いタイムラグでケイデンスを計算する方法を考えたいと思います。

回転数を計算する方法には、大きく２つの方法があります。

- 直接計算方式
- レシプロカル方式

![3-01.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/642018/f9bc6c9c-8649-1684-fc99-57d6ce36c765.png)

![3-02.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/642018/da8b8922-5d1f-092d-48d7-5a56e18471c9.png)



直接計算方式は、図のようにある一定の時間周期Tの間に測定される回転数nを、周期Tで除算した値(n/T)として求める方法です。ここでは時間単位を秒としているので60を掛けて１分間あたりに変換します。なお、ONのパルスには幅があるので、ONとなった瞬間をカウントすることにします。高周波数であれば１秒の短い周期でも計算ができ、少ないタイムラグで算出できそうです。しかし、低周波数の場合には注意が必要です。例えば30rpmの場合は2秒に１回転となるため、1秒の周期では計算できません。低周波数の場合は、計算される一番低い回転速度に合わせて周期を選ばないといけません。

周期Tと回転数nで計算される回転速度の関係を表にしました。低速回転の場合、周期を長く取らないと回転速度が計算できないことや、飛び飛びの値しか表示されないというのがわかります。

<table>
<tr>
<tr>
<th scope="col" colspan="2"  rowspan="2"> </th>
<th scope="col" colspan="4" align=center>回転数n</th>
</tr>
<tr>
<th scope="col"  align=center>１回転</th>
<th scope="col"  align=center>２回転</th>
<th scope="col"  align=center>３回転</th>
<th scope="col"  align=center>４回転</th>
</tr>
<tr>
<td rowspan="4" align=center><b>周期T</b></td>
<td>１秒</td>
<td>60 rpm</td>
<td>120 rpm</td>
<td>180 rpm</td>
<td>240 rpm</td>
</td>
</tr>
<tr>
<td>２秒</td>
<td>30 rpm</td>
<td>60 rpm</td>
<td>90 rpm</td>
<td>120 prm</td>
</td>
</tr>
<tr>
<td>３秒</td>
<td>20 rpm</td>
<td>40 rpm</td>
<td>60 rpm</td>
<td>80 rpm</td>
</td>
</tr>
<tr>
<td>４秒</td>
<td>15 rpm</td>
<td>30 rpm</td>
<td>45 rpm</td>
<td>60 rpm</td>
</td>
</tr>
</table>

これに対し、低周波数でも算出可能な方法がレシプロカル方式です。前のパルスから次のパルスまでの時間間隔τを計測し、その逆数を計算する方法です。時間間隔τは、１回転するのに要する秒数であるため、その逆数1/τは1秒間あたりの回転数になります。直接計算方式と同様に、これに60を掛けて１分間あたりに変換することで、ケイデンスが計算できます。この方式では、直接計算方式と異なり回転速度が飛び飛びの値にならず、低速でも計算できます。ただし、１回転ごとに回転速度が計算されるので変動が大きくなりやすいことや、次のパルスが来るまで回転速度が計算できないため、回転速度０が計算できないという欠点があります。

今回は、レシプロカル方式をベースに、直接計算方式のような時間周期Tで平均化する計算方法で実現することにしました。具体的なアルゴリズムを、周期Tが３秒の場合を例に図で示します。
1. 長さTのnumpy配列を用意し、初期値をすべてnanとします。
2. 時刻tからt+1秒の間に計測されたレシプロカル方式による回転速度の平均値を$r_t$とします。もし、一度も回転しない場合は$r_t$=nanとします。
3. 時間tをTで割った余りを先程の配列のインデックスとし、$r_t$の値を代入します。これにより、ある時間tからT-1秒前までのデータは上書きされず配列に保存されることになります。
4. 時刻tの時点で配列に格納されているnan以外の値の平均値$R$を、時刻$t$での回転速度として計算します。ただし、配列にnanしかない場合は0を返すことにします。

上記の方法では、回転速度の下限がT秒間に１回の場合になります。この回転速度をプラレールのPWMの値にそのまま使う予定ですが、電車が動くかどうかのギリギリの出力が30程度ですし、ペダルを漕ぐ際も30 rpm以下の低速で漕ぐこともほとんど無いので、今回はTを2と設定することにしました。


![3-03.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/642018/70dbf4fc-f498-5c0f-d850-2c07776bc7c7.png)


# プログラムを書き込もう

実際にIoTサイクルメータのプログラムを作成していきます。まずは、液晶ディスプレイ用のライブラリをインストールしておきます。

```shell
$ git clone https://github.com/adafruit/Adafruit_Python_SSD1306.git
$ cd Adafruit_Python_SSD1306/
$ sudo python3 setup.py install
$ sudo apt install fonts-ipaexfont
```

 ユーザーホームにcycle_puls.pyというファイルでプログラムを作成します。作成したプログラムファイルに実行権限を与え、起動時に実行されるように設定をします。

```shell
$ chmod +x /home/pi/cycle_puls.py
$ vi /etc/rc.local
　(以下のように行を変更)
 /home/pi/shutdown.py &
 /home/pi/cycle_puls.py
```

実際のコードは以下のとおりです。

```python
#!/usr/bin/python3
# coding:utf-8
import os
import time
import pickle
import numpy as np
import RPi.GPIO as GPIO
import Adafruit_SSD1306
from datetime import datetime, timedelta, timezone
from PIL import Image, ImageDraw, ImageFont
from pybleno import *

class CallBack:

  def __init__(self):
    GPIO.setmode(GPIO.BCM)
    #4番pinをスタート・リセット
    self.start_pin = 4
    GPIO.setup(self.start_pin,GPIO.IN,pull_up_down=GPIO.PUD_UP)

    # 18番pinをサイクルパルス入力、プルアップに設定
    self.cycle_pin = 18
    GPIO.setup(self.cycle_pin, GPIO.IN, GPIO.PUD_UP)

    # 回転数の平均を取る周期
    self.cycle_ave_interval = 2

    # タイムゾーン
    self.jst = timezone(timedelta(hours=+9),'JST')
    self.utc = timezone.utc


    # 液晶ディスプレイの初期設定
    RST = None
    I2C_ADDR = 0x3c
    font_size = 14
    font_path = "/usr/share/fonts/truetype/fonts-japanese-gothic.ttf"
    self.disp = Adafruit_SSD1306.SSD1306_128_64( rst=RST, i2c_address=I2C_ADDR )
    self.jpfont = ImageFont.truetype(font_path, font_size, encoding='unic')
    self.image = Image.new( '1', ( self.disp.width, self.disp.height ) )
    self.draw = ImageDraw.Draw( self.image )

    self.reset_display()
    self.reset_count()

    # 割り込みイベント設定
    GPIO.add_event_detect(self.cycle_pin, GPIO.FALLING, bouncetime=300)
    # コールバック関数登録
    GPIO.add_event_callback(self.cycle_pin, self.cycle_count)

  # 液晶ディスプレイをリセット
  def reset_display(self):
    self.disp.begin()
    self.disp.clear()
    self.disp.display()

  # メータの値をリセット
  def reset_count(self):
    self.rotation = 0
    self.rpm = []
    self.rpm_interval = np.full(self.cycle_ave_interval,np.nan)
    self.s_time = time.time()
    self.b_time = self.s_time
    self.interval_count = 0
    self.event = []

  # 回転数を更新
  def cycle_count(self, channel):
    self.rotation += 1
    self.rpm.append(60.0/(time.time() - self.b_time))
    self.b_time = time.time()
    self.event.append(time.time())

  # 液晶ディスプレイの表示を更新
  def update_disp(self):
    # 現在のケイデンスを算出
    interval_num = int((time.time() - self.s_time)%self.cycle_ave_interval)
    if self.interval_count != interval_num:
       self.interval_count = interval_num
       self.rpm_interval[interval_num] = sum(self.rpm)/len(self.rpm) if self.rpm else np.nan
       self.rpm = []
    ftime = datetime.fromtimestamp(time.time() - self.s_time ,self.utc)
    mean_rpm = 0 if np.isnan(self.rpm_interval).sum()==len(self.rpm_interval) else np.nanmean(self.rpm_interval)
    # 数字部分だけ表示を更新
    self.draw.rectangle((50,0,self.disp.width,self.disp.height), outline=0, fill=0)
    self.draw.text( ( 50, 5 ), ftime.strftime('%H:%M:%S'), font=self.jpfont, fill=255 )
    self.draw.text( ( 50, 25 ), f"{mean_rpm:0.0f}", font=self.jpfont, fill=255 )
    self.draw.text( ( 50, 45 ), f"{self.rotation}", font=self.jpfont, fill=255 )
    self.disp.image(self.image)
    self.disp.display()
    return mean_rpm

  # 液晶ディスプレイに項目名を表示
  def set_disp(self):
    self.draw.rectangle((0,0,self.disp.width,self.disp.height), outline=0, fill=0)
    self.draw.text( ( 0, 5 ), "TIME:", font=self.jpfont, fill=255 )
    self.draw.text( ( 0, 25 ), " RPM:", font=self.jpfont, fill=255 )
    self.draw.text( ( 0, 45 ), " ROT:", font=self.jpfont, fill=255 )
    self.disp.image(self.image)
    self.disp.display()

  # 液晶ディスプレイにメッセージを表示
  def disp_mesg(self,mesg):
    self.draw.rectangle((0,0,self.disp.width,self.disp.height), outline=0, fill=0)
    self.draw.text( ( 0, 5 ), mesg, font=self.jpfont, fill=255 )
    self.disp.image(self.image)
    self.disp.display()

  # 履歴をpickleで保存
  def dump_events(self):
    if not self.event:
       return
    with open(f"/home/pi/records/{datetime.isoformat(datetime.fromtimestamp(self.event[0]))}.pkl","wb") as f:
      pickle.dump(self.event,f)

  # コールバック関数
  def callback_start(self):
    try:
      self.disp_mesg("Ready?")
      start_flag = 1
      while(True):
        push = GPIO.wait_for_edge(self.start_pin, GPIO.FALLING,timeout=5000)
        # スイッチ操作の処理
        if start_flag:
          self.set_disp()
          self.reset_count()
        elif start_flag==0 and push:
          self.disp_mesg("Bye")
          self.dump_events()
          os.system("sudo shutdown -h now")
          break # システムシャットダウン
        else:
          self.set_disp()
          start_flag = 1 # 続けてボタンを操作しなければ継続

        while(start_flag):
          time.sleep(0.1)
          mean_rpm = self.update_disp() # 0.1秒ごとに表示アップデート
          on_start_button = GPIO.input(self.start_pin) == 0
          on_cycle_button = GPIO.input(self.cycle_pin) == 0
          if on_start_button and not on_cycle_button:
            start_flag = 0 # 赤スイッチ押されたらループから抜ける
          elif on_start_button and on_cycle_button:
            self.reset_count() # 赤と黒のスイッチ同時おしで数値リセット

        self.disp_mesg("Finish?")
        time.sleep(1)

    except KeyboardInterrupt:
      GPIO.cleanup()

if __name__ == '__main__':
  cb = CallBack()
  cb.callback_start() # 割り込みイベント待ち
```



ここで、いくつかポイントを説明します。

1. ディスプレイのアドレスを確認しておく

    今回用いたディスプレイはの、I2C通信のアドレスが「0x3c」に設定されています。念の為、以下の手順でアドレスを確認することができます。

    ```shell
    $ sudo apt-get install i2c-tools
    $ sudo i2cdetect -y 1
    ```

    なお、液晶ディスプレイとパルスオキシメータの２つのアドレスが表示されますが、「0x3c」があることを確認しましょう。


2. ホームに「records」ディレクトリを作成しておく

    デフォルトでは/home/pi/recordsに漕いだ時間を記録したpickleファイルを保存するようにしていますので、予め以下のコマンドでディレクトリを作成してください。

    ```shell
    $ mkdir -p /home/pi/recoreds
    ```

3. ディスプレイのリフレッシュレートは0.1秒

    タイマーを表示する観点で、ディスプレイは0.1秒間隔で描画を更新しています。

4. １回転の検出は割り込みイベントとして取得

    右足のペダルが一番下に来たイベントをトリガーとして回転速度の計算メソッドを呼び出します。なお、１度イベントを検出したら、0.3秒間はイベントの検出をしない設定(bouncetime=300)をしています。これは、ボタンが押された瞬間に接点が振動し、オンオフが連続して発生するチャタリングという現象を防止するための処理です。１秒間に3回転より速く漕ぐのは難しいと考え、0.3に設定しました。



# フィットネスバイクを漕いでみよう 

サイクルメータのモノラルジャックをフィットネスバイクに接続し、電源を入れれば開始です。使い方は以下の通りです。

1. 電源を入れると「Ready?」と表示され、５秒経つとタイマーが動きます。
2. 初回は時刻合わせのズレが発生することがあるので、黒と赤のボタンを同時に押してリセットします。
3. 自転車を漕ぐか、黒いボタンを押すと回転速度が表示され、回転数がカウントアップされます。
4. 終了するときは赤いボタン押すと、「Finish?」と聞かれるので、再度赤いボタンを押すと、これまでの記録が保存され、シャットダウンされます。５秒間何も押さなければ再開されます。
5. 終了したら電源を抜いてください。 

実際にフィットネスバイクを漕いたときの映像がこちらです。

![3-07.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/642018/42edad5b-1a1a-3bb5-aabf-f2b2677d1714.gif)


# おまけ：フィットネスバイクが無いときは・・・

 フィットネスバイクが無いときは別の運動に置き換えてプラレールを動かせたら楽しそうです。フィットネスバイクを漕いだときの電気信号は、スイッチのオンオフと同等であることがわかりましたので、同じように体を動かすことでスイッチのオンオフができれば良さそうです。最近は、運動情報をスマートフォンやスマートウォッチなどで体の動きをセンシングできるようになりましたが、折角なのでセンサも自作してみました。

 傾きを検知するセンサである、傾斜スイッチの原理を参考にしました。傾斜スイッチの原理の一例は図のようになります。２つの電極と１つの金属球で構成されており、傾きがない場合は一方の電極としか接触しておらずオフの状態ですが、傾くと２つの電極に接触しオンの状態になります。
 
 ![3-05.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/642018/59922948-2534-5d03-dd73-fba182000730.png)


これを手近な材料で再現すべく、アルミ箔と手芸で使う銅合金製のビーズ（１００円ショップで売ってます）、銅線、モノラルジャックのオス、レゴブロックを用意しました。
組み上げると、ちょうどレゴブロックの隙間をビーズがスムーズに転がる隙間になっています。

![3-06.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/642018/cf82abc7-218a-b2d0-b8cd-20ad04a55db6.png)



このデバイスを足にくくりつけ、モノラルジャックのオスをサイクルメータと接続すると、フィットネスバイクの代わりに歩数をカウントすることができます。実際の映像がこちらです。これで、フィットネスバイクの代わりに腿上げやその場のランニングなどの運動でも動作できるようになりました。


![3-08.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/642018/2920d808-b0eb-49ac-1c3b-6d6dac08e5bf.gif)


# おわりに

次回の記事では、これまでに作成したIoT電池とIoTサイクルメータを無線で繋げたいと思います。

フィットネスバイクはペダルを漕ぐことでスイッチのオンオフを行っているように、データを取得する仕組みがわかれば、アイディア次第で他の現象にも応用ができることを、簡単な工作で実現してみましたがいかがでしたでしょうか？

最近は、リアルとバーチャルの融合が進んでますが、2つの世界をインタラクティブに繋げる最も基本的な原理は、スイッチのオンオフではないかと思います。単なるスイッチ操作のデータに、「自転車を漕いでいる」や「歩いている」などのコンテクストが加わることで、回転数や歩数などの意味を持った情報になり、リアルな現象をデジタル化できるのが面白いところだと思います。

なお、データ分析でもコンテクスト情報やドメイン知識を活用し、意味のある情報を得るのは有用な手段の一つだと思います。何気ない動作も、どんな原理でデジタル化できそうか？を考えてみるのも面白いかもしれません。

今後は、バーチャルリアリティのコンテンツも自在に作りやすくなってきてるので、オリジナルのデバイスを使ってバーチャル空間に干渉するなんてこともトライしてみたいと思ってます。


#### 商標

「プラレール」は株式会社タカラトミーの登録商標です。

「レゴ」はレゴ　ジュリス　エー／エスの登録商標です。