差分とフィードバック制御
情報制御基礎の授業を通して、入力値を制御するため、コンピュータを使う場合の数値処理の基礎的な話として、信号の平滑化を説明してきたので、最後に差分について説明をする。また、実際には、入力値を制御に利用する一般的な構成のフィードバック制御について説明する。
変化の検出
例えば、以下のような若干のノイズが混ざった入力信号が与えられたとする。この波形で「大きな山が何ヶ所ありますか?」と聞かれたら、いくつと答えるべきであろうか?山の判断方法は色々あるが、4カ所という答えは、1つの見方であろう。では、この4カ所という判断はどうすればいいだろうか?
こういった山の数を数えるのであれば、一定値より高いか低いか…という判断方法もあるだろう。この絵であれば、15ステップ目、32ステップ目付近は、100を越えていることで、2つの山と判断できるだろう。
こういった予め決めておいた値より「上か?/下か?」で判断するときの基準値は、しきい値(閾値:threshold)と呼ぶ。
しかし、この閾値では、40ステップ目から50ステップ目も100を越えており、以下のようなプログラムを書いたら、40ステップ目~50ステップ目すべてをカウントしてしまう。
#define THRESHOLD 100 int x[ 100 ] = { // 波形のデータが入っているとする。 } ; int count = 0 ; for( int i = 0 ; i < 100 ; i++ ) { if ( x[i] >= THRESHOLD ) count++ ; }
また、65ステップ目の小さな山も1個とカウントしてしまう。
この問題を避けるために、閾値を130にすると、今度は最初の2つの山をカウントできない。どうすれば、山の数をうまくカウントできるのだろうか?
差分を求める
前述のような問題で山の数を数える方法を考えていたが、数学で山を見つける時には、何をするだろうか?
数学なら、山や谷の頂点を求めるのならば、微分して変化量が0となる場所を求めることで、極大値・極小値を求めるだろう。そこで、山を見つけるために入力値の変化量を求めてみよう。
表計算ソフトで差分を計算するのであれば、セルに図のような式を入力すればいいであろう。このようなデータ点で前の値との差を差分と呼ぶ。数学であれば、微分に相当する。
このグラフを見ると、波形が大きく増加する部分で、差分が大きな正の値となる。さらに波形が大きく減少する部分で差分が負の大きな値となる。特にこのデータの場合、山と判断したい部分は差分が20以上の値の部分と定義することも考えられる。
#define TH_DIFF 20 int x[ 100 ] = { // 波形のデータが入っているとする。 } ; int count = 0 ; for( int i = 0 ; i < 100 ; i++ ) { if ( x[i] - x[i-1] >= TH_DIFF && x[i+1] - x[i] <= -TH_DIFF ) count++ ; }
しかし、このプログラムでは、山の数をうまくカウントしてくれない。うまく、山の数を数えるためには、差分の値を山と判断するための閾値(この場合は20)を調整することになるだろう。
移動平均との差
前回の講義で示したデータの例で、移動平均を取ると分かる事例ということで、船につけられた加速度センサーで、長い周期の波による船の揺れと、短い周期のエンジンによる振動があったとき、エンジンの振動を移動平均で取り除くことができるという事例を示した。
これを逆手にとれば、元の信号と移動平均の差を取れば、エンジンの振動だけを取り出すことも可能となる。以下は、前の事例で、前後5stepの移動平均(水色線)と元信号(青線)の差をとったものが緑線となっている。このような方法をとれば、元信号の短い周期の変動を抽出することができる。
制御工学の概要
以下に、制御工学ではどのようなことを行うのか、概要を述べる。
ここで紹介する制御理論は、古典制御理論と呼ばれる。
制御工学では、入力値と、何らかの処理を施し出力値が得られるシステムで、どのように制御するかを考える。
例えば、電気ポットの温度制御をする場合、設定温度の値を入力値とし、何らかの処理を行い、出力となるヒーターの電流を制御し、最終的には温度が測定される。ヒーターは、設定温度と温度計の値の差に応じて電流量を変化させる。このように一般的な制御では、最終的な温度が入力に戻っている。このように目標値に近づけるために、目標値との差に応じて制御することをフィードバック制御という。
制御の仕方には様々な方法があるが、 がとある時間で0からYに変化した場合を考える。入力と出力で制御された波形の例を示す。
この波形では、黒のように入力値が変化した場合、それに追いつこうと出力が変化する。(1)理想的には、速やかに追いつく赤のように変化したい。しかし、(2)慎重に制御をする人なら、変化への制動が大きい過制動(青点線)となり、目標値に追いつくまでに時間がかかる。(3)一方、すこしでもずれたら直そうとする人なら、時間的には速い反応ができるかもしれないが、目標値を追い越したり、増えすぎ分を減らしすぎたりして脈動する過制御(赤点線)となるかもしれない。
PID制御
目標値、出力、ずれ(偏差)、制御量とした時、基本的なフィードバック制御として偏差の使い方によってP動作,I動作,D動作がある。参考 Wikipedia PID制御
比例制御(P制御)
偏差に比例した制御を行う方式(を比例ゲインと呼ぶ)
今年のコロナ騒動を例にとるならば、比例制御は、今日の感染者数y(t)と目標としたい感染者数x(t)の差に応じて、対策の強さu(t)を決めるようなもの。
積分制御(I制御)
偏差のある状態が長い時間続く場合、入力値の変化を大きくすることで目標値に近づけるための制御。(は積分ゲイン)
積分制御は、目標の感染者数x(t)を感染者数y(t)が超えた累積患者数に応じて、対策を決めるようなもの。
移動平均は、一定範囲の値の和(を範囲のデータ数で割ったもの)であり、積分制御は移動平均の値に応じて制御するとみなすこともできる。
微分制御(D制御)
急激な出力値の変化が起こった場合、その変化の大きさに応じて妨げようとする制御。(は微分ゲイン)
微分制御は、目標数と感染者数の差が、前日よりどのぐらい増えたか(患者の増減の量:変化量)に応じて、対策を決めるようなもの。
PID制御
上記のI制御やD制御だけでは、安定させることが難しいので、これらを組み合わせたPID制御を行う。
この中で、の値は、制御が最も安定するように調整を行うものであり、数値シミュレーションや、ステップ応答を与えた時の時間的変化を測定して調整を行う。
ライブラリと分割コンパイル
巨大なプログラムを作ってくると、プログラムのコンパイルに時間がかかる。こういった場合には、共有できる処理であればライブラリにまとめたり、分割コンパイルといった方法をとる。
ライブラリ
C言語でプログラムを作っている時、printf() や scanf() といった関数を使うが、こういった組み込み関数のプログラムはどこに書かれているのだろうか?
ソースプログラムがコンパイルする際には、コンパイラ(compiler)によるコンパイル処理(compiler)、リンカ(linker or linkage editor)によるリンク処理(link)が行われる。この時に、printf()やscanf() の機械語(組み込み関数などのライブラリの内容)が実行プログラム a.out の中に埋め込まれる。通常は、コンパイルとリンク処理は一連の処理として実行される。
helloworld.c ソースプログラム ↓ compiler $ gcc -c helloworld.c (コンパイルだけ行う) helloworld.o オブジェクトファイル(中間コード) ↓ linker $ gcc helloworld.o (リンク処理を行う) (+) ← libgcc.a ライブラリ(printf, scanf....) ↓ $ ./a.out a.out 実行プログラム
静的リンクライブラリと動的リンクライブラリ
しかし、printf() や scanf() のような組み込み関数の機械語が実行プログラムの中に単純に埋め込まれると、
- よく使われるprintf()やscanf()の処理は、沢山の実行プログラムの中に埋め込まれる。
そして、組み込み関数を使ったプログラムが複数実行されると、実行中のメモリに複数の組み込み関数の処理が配置されてメモリの無駄が発生する。 - その組み込み関数に間違いがあった場合、その組み込み関数を使った実行プログラムをすべて再コンパイルしないといけなくなる。
リンクされたプログラムの機械語が実行プログラムに埋め込まれる方式は、静的リンクライブラリと呼ぶ。
しかし、静的リンクライブラリの方式は、実行時の命令の領域のムダや、ライブラリに間違いがあった場合の再コンパイルの手間があるため、動的リンクライブラリ方式(共有ライブラリ方式)がとられる。
動的リンクライブラリでは、プログラム内には動的リンクを参照するための必要最小限の命令が埋め込まれ、命令の実体は OS が動的リンクライブラリとして管理する。
Linux では、静的リンクライブラリのファイルは、lib~.a といった名前で保存され、動的リンクライブラリは、lib~.so という名前で保存されている。 Windows であれば、拡張子が ~.DLL のファイルが動的リンクライブラリである。
OS にとって必須の動的リンクライブラリは /lib 配下に保存されるが、ユーザが独自にインストールするパッケージの場合 /lib のアクセス権限の都合で別の場所に保存されるかもしれない。この場合、その独自パッケージを起動する時に、動的リンクライブラリの保存場所を見つけ出す必要がある。Linux では 環境変数 LD_LIBRARY_PATH に自分が利用する動的リンクライブラリの保存場所を記載すれば、OS がプログラム起動時に動的リンクライブラリを見つけてくれる。
分割コンパイル
複数人でプログラムを開発する場合、1つのファイルを全員で編集するのは混乱してしまう。例えば、ちょうど情報構造論で説明している、リスト処理のようなプログラムであれば、List 構造の構造体、cons(),print() といったList 構造を操作する関数を作る人と、そのそれらの関数を利用するプログラムを書く人に分かれてプログラム開発をすれば混乱も減らせる。そして、それぞれ別のファイルになっている方が開発しやすい。
- list.h : ヘッダファイル – 構造体の宣言や関数のプロトタイプ宣言や変数のextern宣言などを記載
- list.c : リスト処理の cons,print などの処理内容を記載
- main.c : cons,print を使った処理を記載
#include “ヘッダファイル”
自作のヘッダファイルを読み込む場合は、#include “list.h“ のように記載する。
#include で、ヘッダファイルを < > で囲むと、/usr/include フォルダから探してくれる。” “ で囲むと、ソースプログラムと同じフォルダの中からヘッダファイルを探す。
プロトタイプ宣言と extern 宣言
ヘッダファイルには、list.c と main.c の両方で使われるデータ構造、関数、変数の宣言を記載する。関数は、引数の型や返り値の型を記載した struct List* cons( int , struct List*) ; といったプロトタイプ宣言を記載する。変数については、変数の型だけを宣言する extern struct List* stack ; といった extern 宣言を記載する。
// list.h ----------------------------- // リスト構造の宣言 struct List { int data ; struct List* next ; } ; // リスト操作の関数のプロトタイプ宣言 extern struct List* cons( int , struct List* ) ; extern void print( struct List* ) ; // stack の extern 宣言 extern struct List* stack ; // スタック操作関数のプロトタイプ宣言 extern void push( int ) ; extern int pop()
// list.c ----------------------------- #include <stdio.h> #include <stdlib.h> #include "list.hxx" // リストの要素を作る struct List* cons( int x , struct List* n ) { struct List* ans = (struct List*)malloc( sizeof( struct List ) ) ; if ( ans != NULL ) { ans->data = x ; ans->next = n ; } return ans ; } // 全要素の出力 void print( struct List* p ) { for( ; p != NULL ; p = p->next ) printf( "%d " , p->data ) ; printf( "\n" ) ; } // stack の実体 struct List* stack = NULL ; // スタックに x を保存 void push( int x ) { stack = cons( x , stack ) ; } // スタックの先頭を取り出す int pop() { int ans = stack->data ; struct List* d = stack ; stack = stack->next ; free( d ) ; return ans ; }
// main.c ----------------------------- #include <stdio.h> #include "list.hxx" int main() { struct List* top = cons( 1 , cons( 2 , cons( 3 , NULL ) ) ) ; print( top ) ; push( 11 ) ; push( 22 ) ; push( 33 ) ; printf( "%d\n" , pop() ) ; printf( "%d\n" , pop() ) ; printf( "%d\n" , pop() ) ; return 0 ; }
分割コンパイルの作業を確認するために、以下のコマンドを実行してみよう。
((( 一度にコンパイルする方法 ))) guest00@nitfcei:~$ cp /home0/Challenge/seg-compile/* . guest00@nitfcei:~$ gcc list.c main.c guest00@nitfcei:~$ ./a.out # 正しく実行できる。 ((( 失敗するコンパイル ))) guest00@nitfcei:~$ gcc list.c /usr/bin/ld: /usr/lib/gcc/x86_64-linux-gnu/9/../../../x86_64-linux-gnu/Scrt1.o: in function `_start': (.text+0x24): undefined reference to `main' collect2: error: ld returned 1 exit status # list.c の中に main() が無いからエラー guest00@nitfcei:~$ gcc main.c /usr/bin/ld: /tmp/ccxr4Fif.o: in function `main': main.c:(.text+0x17): undefined reference to `cons' /usr/bin/ld: main.c:(.text+0x24): undefined reference to `cons' /usr/bin/ld: main.c:(.text+0x41): undefined reference to `print' : collect2: error: ld returned 1 exit status # main.c の中に、cons(),print(),push(),pop() が無いからエラー ((( プログラムをひとつづつコンパイル ))) guest00@nitfcei:~$ gcc -c list.c # list.o を作る guest00@nitfcei:~$ gcc -c main.c # main.o を作る guest00@nitfcei:~$ gcc list.o main.o # list.o と main.o から a.out を作る guest00@nitfcei:~$ ./a.out # 正しく実行できる。
make と Makefile
上記のように分割コンパイルのためにファイルを分割すると、実行プログラムを生成するには以下のコマンドを実行する必要がある。
- gcc -c list.c (list.o を作る)
- gcc -c main.c (main.o を作る)
- gcc list.o main.o (list.oとmain.oを使って a.out を作る)
また、プログラムのデバッグ作業中ですでに list.o , main.o , a.out が生成されている状態で、main.c の中に間違いを見つけて修正した場合、list.o を作るための 手順1 は不要となる。例えば list.c が巨大なプログラムであれば、手順1を省略できれば、コンパイル時間も短くできる。一方で、どのファイルを編集したから、どの手順は不要…といった判断をプログラマーが考えるのは面倒だったりする。
こういった一部の修正の場合に、必要最小限の手順で目的の実行プログラムを生成するためのツールが make であり、どのファイルを利用してどのファイルが作られるのかといった依存関係と、どういった手順を実行するのかといったことを記述するファイルが Makefile である。
### Makefile ### # a.out を作るには list.o , main.o が必要 a.out: list.o main.o # 最終的に生成する a.out の依存関係を最初に書く gcc list.o main.o # list.o は list.c , list.h に依存 list.o: list.c list.h gcc -c list.c # main.o は main.c , list.h に依存 main.o: main.c list.h gcc -c main.c clean:; rm *.o a.out # 仮想ターゲット: make clean と打つと、rm *.o a.out を実行してくれる。
Makefile では、依存関係と処理を以下の様に記載する。make コマンドは、ディレクトリ内の Makefile を読み込み、ターゲットファイルのタイムスタンプと依存ファイルのタイムスタンプを比較し、依存ファイルの方が新しい場合(もしくはターゲットファイルが無い場合)、ターゲットを生成するための処理が行われる。
ターゲットファイル: 依存ファイル ... ターゲットファイルを生成する処理 # 行の先頭の空白は"タブ"を使うこと