Raspberry Piで地震計を作る ③ センサ・全体構成編
はじめに
Raspberry Piによるマイ地震計を作るため,前々回にて計測震度計算プログラムを,前回にてタイマ割込みやマルチプロセッシングなどリアルタイム処理を作って参りました。今回は,Raspberry Piに加速度センサとOLEDディスプレイを接続し,プログラム全体を組み上げていきます。
前々回,前回挙げた開発課題を再掲します。
- 震度計算プログラムの実装
- システムコールsetitimerを活用したタイマ割り込み
- マルチプロセッシングとキュー
- タイマ割り込みとマルチプロセッシングの組み合わせ
- 加速度センサからの加速度取得
- OLEDディスプレイへの表示
- マイ地震計全体のプログラムの組み立て
今回は,上記5,6,7にあたる処理をPythonにて作って行きます。また,加速度センサ(モーションセンサ)の分解能やノイズについて,初歩的な実験を行って確認しました。
目次
- はじめに
- 目次
- 加速度センサをRaspberry Piに繋げる
- OLEDディスプレイをRaspberry Piに繋げる
- マイ地震計全体のプログラムの組み立て
- まとめ
- 付録: I2Cバスを分ける? 分けない?
加速度センサをRaspberry Piに繋げる
ADXL345からMPU6050へ
現在,マイ地震計プロトタイプではInvenSense社のモーションセンサMPU6050を用いていますが,試作を始めた当初は,何も考えずにAmazonで購入したAnalog Devices社のADXL345を用いていました。図1にそれぞれのセンサを搭載した2つのモジュールを示します。
ADXL345の最大分解能は256 LSB/g = 3.9 mg/LSBとなっています*1。当初,「ミリg」なんて小さな加速度が測れるのかと感心していたのですが,これを1g = 9.80665 m/s2として単位を[gal/LSB]に換算すると,3.83 gal/LSBとなります。ぐぬぬ…?
代わりのセンサとして,InvenSense社のMPU6050を見つけました。最大分解能はADXL345の64倍の16,384 LSB/g = 0.061 mg/LSBであり,[gal/LSB]に換算すると0.060 gal/LSBとなります。図2を見るとこれなら十分に思えます。
(あれ…? 「Not recommended for new designs」という言葉が…💦)
MPU6050のノイズ
ところが…。データシートにはノイズのパワースペクトル密度として400 μg/√Hzと記載されています。サンプリング周波数が100 Hzであることから,測定系の帯域をその半分の50 Hzとすると,ノイズの実効値Anは
と推定できます…。ぐぬぬ…。ADXL345よりは若干改善されてはいるものの,これではせっかくの分解能がフルに活かされないかもしれませんね…。また,ADXL345が分解能を256 LSB/gに留めているのも,ある意味で正しい態度なのかもしれません。
次節のようにMPU6050をRaspberry Piに接続し,静止状態での加速度を測ってみました。筆者はMPU6050を搭載したモジュールを2つ持っているので,それらをI2Cバスに並列接続し*2,同時に測定しました。また,MPU6050内蔵のディジタルローパスフィルタ(DLPF)の設定を,
- DLPF_CFG = 0(DLPFなし,帯域260 Hz)
- DLPF_CFG = 2(DLPFあり,帯域94 Hz)
とした2パターンで測定します*3。結果を図3,4に示します。
また,測定した加速度の実効値(root-mean-square)を表1にまとめました。前々回で作成したshindo.pyにて計算した計測震度と震度階級も記載しました。
DLPF_CFG | センサ | x軸 [gal] | y軸 [gal] | z軸 [gal] | 計測震度 | 震度階級 |
---|---|---|---|---|---|---|
0 | センサ0 | 3.27 | 3.34 | 4.81 | 1.8 | 2 |
センサ1 | 3.23 | 2.92 | 4.86 | 1.8 | 2 | |
2 | センサ0 | 2.00 | 1.92 | 3.19 | 1.5 | 2 |
センサ1 | 2.03 | 1.90 | 3.09 | 1.4 | 1 |
図3より,DLPF_CFG = 0としてDLPFを無効にすると,x,y,z軸それぞれの加速度のピークは10 galを超えており,実効値では3 ~ 5 gal程度になります。z軸のノイズが大きいように見えますね(机が本当に振動していた…?)。また,(1)式の2.77 galより大きめの値になっています。計測震度は1.8となりました。何も揺れていないのに震度2です…。
一方,図4より,DLPF_CFG = 2としてDLPFを有効(帯域94 Hz)にすると,x,y,z軸それぞれの加速度のピークはほぼ10 galを下回り,実効値では2 ~ 3 gal程度になります。これは(1)式の値に近いですね。計測震度は各センサでそれぞれ1.5,1.4となりました。
やはり,震度1以下の検知は難しそうです。2つのセンサを常時並列接続し,その測定値の平均をとることで,ノイズを1 / √2に抑制しようと試みたりもしましたが,MPU6050を1つは空けておきたいので,ひとまず,センサ1つ,DLPF_CFG = 2として計測震度2以上の検知に絞ることにしました。震度1以下は測れませんね…。
なお,MPU6050の後継製品であるICM-20602ではノイズのパワースペクトル密度が100 μg/√Hzに改善されているようです。また,データシートにおけるノイズ関係の情報の提示方法についても見直されています(DLPF_CFGの値に対応するデータとノイズのバンド幅が個別に与えられています)。
図3,4の波形がそのまま1 / 4になったと仮定して計測震度を計算すると,余裕をもって1.0を下回ります。希望の光が見えるではないか…✨ MPU6050と比べるとちょっとお高いですが,個人でもICM-20602を搭載したモジュールは入手できそうです。今後,試してみたいと思います。
さて,改めて図2を見ると,地震計として震度1以下を捉えようとするとノイズを含めて0.4 gal (= 0.401 mg)以下の小さな加速度を正確に測定できるセンサが必要と考えます。気象庁の震度計ではどのように加速度を検知しているのか,俄然興味が湧いてきました。
I2Cバスでの接続
ADXL345もMPU6050もI2Cバスで各種のマイクロコントローラに接続します。Raspberry Piも当然I2Cバスを持っており,GPIO2 (SDA),GPIO3 (SCL)として40ピンのピンヘッダに出ています。接続のイメージは前々回の図1の通りです。図3として再掲します。[追記: 2021-11-19]同じI2CバスにモーションセンサMPU6050とOLEDディスプレイを繋げると,加速度の定期的な読み出しのサイクルが乱される可能性があることが判りました。詳しくは付録をご覧下さい。
筆者もこれまでいくつかのI2Cデバイスを使ってみましたが,デバイス毎のライブラリに頼ることが多く,自分でデバイスとの(I2Cプロトコルの上位となる)通信を実装することはほとんどありませんでした。今回は勉強がてら,pigpioライブラリを用いて加速度センサからデータを取得するPythonモジュールを自分で書くことにしました*4。
概ね,このようなセンサデバイスは内部にレジスタと呼ばれる8 bitずつの記憶域をもっており,各レジスタにはレジスタ番号(これも「アドレス」と呼ばれることが多く,若干の混乱を招きます)が割り当てられております。各レジスタには,センサの動作を制御するための設定値や,測定した結果が格納されており,I2Cバスを介してそれぞれのレジスタに対して書き込んだり読み込んだりといった指示を送るような使い方になっています。
図4にI2Cマスタ(Raspberry Pi)からI2Cスレーブ(MPU6050)内の1つのレジスタに1 byteのデータを書き込む場合のイメージを示します。SDA,SCLともに3.3 Vの電源にプルアップされており,マスタ,スレーブとも,通信が始まる前にはハイインピーダンスになっています。SCLは(通常は)常にマスタから駆動されますが,SDAに注目すると,マスタとスレーブにそれぞれスイッチsmとssがあります(実際はSoCの中のMOSFETです)。通信していない場合はともにoffとなっています。
マスタから,スレーブ内の1つのレジスタに1 byteのデータを書き込む場合,マスタがスタートコンディション*5を発行し,続いてI2CスレーブアドレスA6 ~ A0(MPU6050であれば0x68または0x69)に続いて書き込みであればW (= LOW)を出力します。スレーブはACKとしてスイッチssをonし,SDAをLOWに駆動します。次にマスタからスレーブ内の書き込みたいレジスタの番号R7 ~ R0とそこに書き込みたいデータD7 ~ D0を送ります。RとD,それぞれの後にはスレーブがACKを出力します。最後にマスターがストップコンディション*6を発行し,通信は終了します。
反対に,I2Cマスタ(Raspberry Pi)がI2Cスレーブ(MPU6050)内の1つのレジスタから1 byteのデータを読み込む場合,図5のようなイメージとなります。ここで,図5で「Caution!」と赤矢印で示した「W」にご注目下さい。筆者のような初心者に対してややこしいのは,I2Cスレーブ内のレジスタの番号R7 ~ R0を指定することはあくまでも「書き込み」であるため,I2Cスレーブの7 bitのアドレスA6 ~ A0の後の1 bitはW (= LOW)としなければならないことです。その後,スレーブ内の読み出したいレジスタの番号R7 ~ R0をマスタから書き込みます。マスタは再度スタートコンディションを発行し,7 bitのI2CスレーブアドレスA6 ~ A0と今度はR (= HIGH)を送ると,以降,スレーブ内のスイッチssがSDAを駆動し,SDAにはデータD7 ~ D0が現れます。マスタはこれを受信し,D0の後でNACKとしてSDAをHIGHとします(スイッチsmをoff)。最後にストップコンディションを発行します。
図4,図5の動きは,pigpioライブラリではそれぞれi2c_write_byte_dataメソッドと,i2c_read_byte_dataメソッドを呼ぶことで簡単に実現できます。また,図4,図5ではデータが1 byteでしたが,連続したレジスタに複数byteをいっぺんに書き込む,読み込むことも可能であり,pigpioライブラリではそれぞれi2c_write_i2c_block_dataメソッド,および,i2c_read_i2c_block_dataメソッドが対応します。次節では,MPU6050からx,y,z軸の加速度を読み込みにあたり,i2c_read_i2c_block_dataメソッドを使っています(8行目(*))。
PythonでMPU6050クラスを作る
筆者の作ったモジュールmpu6050.pyでは,mpu6050.MPU6050というクラスを定義しています。しかし,MPU6050のすべての機能を活かすようなプログラムはまだ実装していません(例えば回転角速度はまだ読み取れません)。一部のレジスタに対する読み書きを通じて,加速度データを読み取れるようになっています。
プログラム全文は長くなってしまいますので,加速度を読み取るmeasureAccelメソッドのみ以下に抜粋します。
class MPU6050: (中略) # Measure acceleration def measureAccel(self, unit: str = 'g') -> Tuple[float, float, float]: # Read from DATAX0 to DATAZ1 (b, d) = self._pi.i2c_read_i2c_block_data(self._h, MPU6050.ACCEL_XOUT_H, 6) # ----(*1) if MPU6050.DEBUG: print(f'Bytes read: {b}') print(f'Raw data: {d}') # Unpack data if b > 0: (x_raw, y_raw, z_raw) = struct.unpack('>3h', d) # ----(*2) else: raise Exception(f'Data acquisition from device on I2C bus {MPU6050.I2C_BUS}, address {MPU6050.I2C_ADDR:#02x} failed') (x_raw, y_raw, z_raw) = (0, 0, 0) # Subtract software offsets x_raw -= self._ofsx y_raw -= self._ofsy z_raw -= self._ofsz # Calculate and return acceleration in specified unit and return if unit == 'g': coeff = MPU6050.RES elif unit == 'gal': coeff = MPU6050.RES * MPU6050.G2GAL elif unit == 'm/s**2': coeff = MPU6050.RES * MPU6050.G2MPSSQ elif unit == 'raw': coeff = 1.0 else: raise ValueError('No such unit supported') return (x_raw * coeff, y_raw * coeff, z_raw * coeff) (後略)
8行目(*1)にて,pigpioライブラリのi2c_read_i2c_block_dataメソッドを用いて,ACCEL_XOUT_Hレジスタ(番号0x3B)から連続する6つのレジスタ(6 byte)のデータを読み取り,変数dにバイト列として格納します。この6 byteにはx,y,z軸方向の加速度が2 byteずつ,ビッグエンディアンで格納されています*7。16行目(*2)にてstructモジュールのunpack関数を使い,バイト列からx_raw,y_raw,z_rawの3軸に分解します。その後,別途記録しておいたオフセットを減算し,さらにgalやm/s2などに換算してreturnします。
OLEDディスプレイをRaspberry Piに繋げる
図6にOLEDディスプレイの例を示します。最近はI2Cバスで接続する小型のOLEDディスプレイが安価で手に入るので,ひと昔前のように,1,000円以上するパラレル接続のキャラクタ液晶を使うことは,個人的にはなくなってしまいました*8。配線も少なく,ちょっとした情報表示に重宝します。
秋月電子通商でもAmazonでも,図6とほとんど同じ外形をしたOLEDディスプレイが多数販売されています。内部ではSSD1306という有名なコントローラICを用いているようで,Raspberry PiでもSSD1306を介してOLEDディスプレイを制御するライブラリがいくつかあります。今回は,モーションセンサMPU6050と同じように,pigpioライブラリを活用したSSD1306用ライブラリとして,Franck Barbenoire (frankinux)さんのoledライブラリを使わせて頂きました。
OLEDディスプレイもモーションセンサMPU6050も同じI2Cバスに並列接続しております。[追記: 2021-11-19]OLEDディスプレイとモーションセンサを別々のI2Cバスに分けました。詳しくは付録をご覧下さい。
マイ地震計全体のプログラムの組み立て
プログラム全体像
前々回,前回,今回を通じて,マイ地震計に必要な要素技術を開発して参りました*9。ここではいよいよそれらを組み合わせ,マイ地震計プロトタイプのPythonプログラムを組み上げて行きます。プログラム全文は長くなってしまうので掲載せず,プログラムの骨組みと全体としての動きを説明します。プログラムそのものについてはGitHubリポジトリをご覧下さい。
図7にPythonプログラム全体の構造を示します。図7(a),(b)は,リアルタイム処理のために立ち上げる子プロセスを担うproc関数と,その中でシグナルハンドラとなるhandler関数です。MPU6040から10 ms周期で加速度データを取得します。
(c)はメインルーチンです。pigpio,mp6050,oledの各モジュールを通じてGPIOと外部デバイスを初期化します。その後,(d)のwhileループに入ります。(d)は地震計全体のループとなります。ここで,加速度のデータを記録するNumPyのndarrayを初期化しておきます。(e)のwhileループでは,モーションセンサMPU6050から加速度を取得しながら,その加速度がある閾値を超えていないか,常にチェックします。加速度がある閾値を超えていたら地震が発生したとみなし,break文でループを抜けます。すぐ下に,キューとプロセスを定義する箇所があります。proc関数を新しいプロセスのターゲットとして設定し,p.start()
にて子プロセスを起動します。メインルーチンはその後,(f)のwhileループに入ります。地震が継続している間,メインルーチンの処理は(f)の中に留まります。
子プロセスは(a)のproc関数です。ここでシグナルハンドラとして(b)のhandler関数を設定します。proc関数自体はタイマ割込みを設定した後は何もせず,無限ループに入ります。handler関数は設定した10 ms周期で定期的に呼ばれます。handler関数に中で,I2Cバスを介してモーションセンサMPU6050から加速度データを読み出します。読み出した加速度データはすぐにキューにputします。
メインルーチンは(g)のfor文に入ります。(g)の中でNDATA = 300点の加速度データをキューからgetし,これをNumPyのndarrayに書き込んでいきます。NDATAは短時間計測震度を計算するための3秒分のデータ点数です。for文を抜けると,前々回で作成したshindoモジュールのgetShindo関数を呼び,計測震度を計算します。このときにも,子プロセスでは10 ms毎の加速度データ取得が継続しています。
計測震度が2.0を下回り,かつ,地震発生からの時間が30秒を超えていたら,地震が終了したみなして(f)からbreak文で抜け出します。(f)を出たら子プロセスをterminateメソッドで停止します。また,結果をOLEDディスプレイに表示したり,加速度のを記録したNumPy ndarrayをpickeとしてファイルに保存したりと言った処理の後,一定時間後に(d)のwhileループの先頭に戻り,次の地震に備えます。
観測例と今後の課題
観測例については,前々回の冒頭をご参照ください。
I2CバスをモーションセンサとOLEDディスプレイで共有しており,かつ,別々のプロセスからpigpioライブラリを介して制御しているため,通信のタイミングが重なってしまうことがあるのではないかと推測します。このような場合にどのような処理となるのか,筆者はよく理解していません。究極的にはpigpioライブラリのソースコードを見ないと良く分からないのかもしれませんね。また,Linuxそのものについてもド素人の知識・経験しか持ち合わせていないため,まだまだ勉強が必要だと痛感しました。
まとめ
今回はマイ地震計プロトタイプに用いる加速度センサとしてADXL345とMPU6050について述べ,結果として使用することになったMPU6050のノイズを測りました。また,I2Cバスでのデータの読み書きについて,簡単に触れています。OLEDディプレイとして,コントローラSSD1306を用いたものをI2Cバスに接続しました。
さらに,マイ地震計プロトタイプのPythonプログラムの全体構成について説明しました。前回述べたリアルタイム処理と加速度センサの読み取りを合体させ,かつ,地震の発生を待つ部分などを追加しております。
前回,前々回,今回を通じて,マイ地震計プロトタイプは一応の完成を見ました(まだブレッドボードに載っているので,ユニバーサル基板ぐらいには載せたいと思っています)。ただし,MPU6050のノイズが思いの外大きいので,後継センサであるICM-20602を入手して,性能を確認してみたいと考えております。
3回にわたって,Raspberry Piでマイ地震計を作る記事を書いて参りました。この駄文にお付き合い下さり,誠にありがとうございます。本ブログでは昨今,ぱわみのある記事を書いていないので,次回辺りはパワーを感じられる記事にできればという気もしております。
付録: I2Cバスを分ける? 分けない?
本付録は2021年11月19日に追記したものですが,その後,2022年3月15日に更新したものです。結論としては,I2Cバスは分けなくても問題なさそうであることが判りました。
ゾンビプロセス
マイ地震計のプログラムを動かして実際の地震が発生するのを待っていても,いつの間にかプログラムが停止しており,加速度を読み出すための子プロセスがゾンビプロセスなっておりました。図8のようにpsコマンドで確認すると<defunct>とマークがついています。
恐らくですが,2つのI2Cデバイスを別々のプロセスから制御しているため,正常に通信できない場合があるのではないかと考えました。
定周期性の乱れ
これまでの説明では,モーションセンサMPU6050とOLEDディスプレイを同じI2Cバスに並列接続しておりました。このような接続で問題ないと考えていたのですが,それぞれのデバイスが別々のプロセスから読み書きされるため,信号の衝突が起こる可能性があるのではないかという疑問が湧いてきました。pigpioライブラリのドキュメントを見ても,このような場合の挙動に関する記述は見つけられませんでした。そこで,オシロスコープで確認してみました。
図9に,2つのデバイスが同じI2Cバス(/dev/i2c-1)に接続している場合の波形を示します。これは地震を検知して,10 ms毎に加速度データを取得している期間での波形です。CH1は共通のSDAです。CH2はデバッグ用に出力した信号で,OLEDディスプレイへの出力が完了した際にトグルしております。波形を見ると,加速度データを取得する周期である10 msよりもOLEDディスプレイを制御する信号の期間が長いため,加速度データ取得の定周期性が乱されている可能性があります。[追記: 2022-03-15]実質的に問題ない程度であることが判りました。下記「同じI2Cバスだった場合の再確認」をご覧下さい。
別々のI2Cバスへ
そこで,図10のように,MPU6050とOLEDディスプレイを別々のI2Cバスに接続しました。
これを実現するために,Linuxが持っているデバイスツリーオーバーレイという機能を用いて,Linuxがソフトウェア的に模擬しているI2Cバスを有効にします。ここでは,みかんさんの下記ブログ記事を参考にさせて頂きました。
sudo nano /boot/config.txt
としてconfig.txtを開き,最後に下記の1行を付け加えます。
dtoverlay=i2c-gpio,i2c_gpio_sda=23,i2c_gpio_scl=24,i2c_gpio_delay_us=1
その後,sudo reboot
として再起動すると,筆者の環境ではもともとの/dev/i2c-1に加えて,図11のように/dev/i2c-11という新しいI2Cバスが現れました。ここでは,モーションセンサMPU6050を元々の/dev/i2c-1 (I2C bus 1),OLEDディスプレイを/dev/i2c-11 (I2C bus 11)に接続しました。
Pythonプログラムの側では,下記のようにoledライブラリでSSD1306をインスタンス化する際に,port = 11とすれば,それ以降はこれまで通りに画面表示できました。
(前略) BUS_OLED = 11 ADDR_OLED = 0x3c (中略) oled = ssd1306(port = BUS_OLED, address = ADDR_OLED) (後略)
この場合の波形を図12に示します。図12のCH1は/dev/i2c-1のSDA (GPIO2),CH2は/dev/i2c-11のSDA (GPIO23)です。
MPU6050から10 ms周期で加速度データを取得している最中に,OLEDディスプレイとの信号のやり取りが行われておりますが,2つのI2Cバスは独立に通信しているため,MPU6050の定周期性は乱されておりません。今回の用途にはこのようにI2Cバスを分けることが適切であると思われます。
同じI2Cバスだった場合の再確認[追記: 2022-03-15]
図9,12はオシロスコープで計測したため,I2Cの信号のフレーム構成が具体的には分かりません。激安ロジックアナライザLHT00SU1を購入しましたので,再度,モーションセンサMPU6050とOLEDディスプレイを同じI2Cバスに接続して(OLEDディスプレイを再びブレッドボード上に戻して)I2Cのフレームを分析してみました。図13にLHT00SU1によるI2Cフレーム分析の様子を示します。
図14,15に示すように,同じI2Cバスに接続すると,OLEDディスプレイ用のI2CフレームがI2Cバスに流れている期間で,MPU6050による加速度のサンプリングのタイミングが10 ms毎にやってきますが,OLEDディスプレイ用のフレームには隙間がある(すべてのデータが1フレームにまとめられているわけではない)ため,10 ms毎のサンプリングタイミングの直後に存在する隙間にMPU6050用のフレームが割り込んでいる様子が確認できます。
OLEDディスプレイ用の1フレームの長さがおよそ770 μsなので,その分,若干ですが10 msからズレます。とは言っても許容範囲な気もします(加速度自体は10 ms毎に取得されているはず)。今さらですが,I2Cバスを分けなくても何とかなったのかもしれないと考えています(LinuxのデバイスツリーオーバーレイによってI2Cバスを増やす練習にはなりました)。
*1:ここでの「g」はグラムではなく重力加速度です。
*2:AD0ピンをGNDとVCCのいずれに接続するかによって,I2Cスレーブアドレスを0x68あるいは0x69とするか選べます。したがって,2つまで同じI2Cバスに接続できます。
*3:https://invensense.tdk.com/wp-content/uploads/2015/02/MPU-6000-Register-Map1.pdf
*4:まずADXL345のためのPythonモジュールを作りましたが,前述の問題からこれをMPU6050のために改造しました。
*5:SCLがHIGHでSDAをLOWにする。
*6:SCLがHIGHでSDAをHIGHにする。