PIDゲインの決め方とC言語実装|Arduinoで10パターン実測した
式は読んだ、コードも写した。それでも、目の前の装置でKp・Ki・Kdをいくつにすればいいのかが決まらない。
ゲインは、先に制御対象を測ってから計算で出します。勘で決めるものではありません。この記事では、LEDの明るさをフォトレジスタで測って合わせる装置をArduinoで組み、Kp・Ki・Kdを10パターン振って実測した波形を並べます。部品は4点、測定は全部で約2分です。
<自己紹介>筆者は現役の車載組み込みソフトエンジニアです。仕事ではRH系・SH系のマイコンで制御ソフトを書いています。この記事の数値は、すべて手元のArduino Uno R3で測ったものです。
目次
- 目標値(SP)
- そうしたい値。この記事では明るさ25%など。
- 測定値(PV)
- いま実際にそうなっている値。センサで読んだ値。
- 偏差(e)
- 目標値と測定値の差。PID制御はこれをゼロに近づける。コードでは
errorと書く(errorという変数名にした理由)。 - 操作量(MV)
- 制御が出す指令。この記事ではLEDに与えるPWM値(0〜255)。
- 行き過ぎ(オーバーシュート)
- 目標値を通り越してしまった量。
- むだ時間
- 操作量を変えてから、測定値が動きはじめるまでの時間。
まず、PID制御がどういう形をしているかを1枚で見ておきます。

やっていることは「測る → くらべる → 動かす → また測る」の繰り返しだけです。輪の中でPIDが担当するのは制御器の箱ひとつで、残りは装置の側にあります。
3つとも見ているのは同じ「偏差」で、どの見方をするかが違います。
P=いまの差に比例して押す。反応は速いが、押し切ると差が残る(Kpを1と8で振った実測)
I=これまでの差を積み上げて埋める。残った差を消せるが、溜まりすぎる(Kiを足した実測)
D=差の変化を見て先回りする。行き過ぎを抑えるはずだが、生のまま使うと逆に振動した(Kdを足した実測)
この記事は3つを1つずつ足して、そのたびに波形がどう変わるかを実測で見ていきます。
今回作るもの:部品4点・配線10分
LEDを光らせて、その明るさをフォトレジスタで測り、目標の明るさになるようPWMを調節します。使うのはArduino Uno R3のほか、LED・220Ω・フォトレジスタ・10kΩの4点だけです。トランジスタも電池も要りません。配線は10分ほどで終わります。
この装置を選んだ理由は2つあります。
1つは速さ。1パターンが5〜9秒で終わるので、9本を約2分で取り切れます。ゲインを振って比べる作業は回数が要るので、1分待つ相手だと同じことが1日仕事になります。
もう1つは明るさが対数で返ってくること。同じ装置のまま、どの明るさで使うかによって必要なゲインが変わるところまで見えます。「ゲインの決め方」を扱う記事なら、ここまで見せられる相手のほうが役に立ちます(動作点が変わると、ゲインも変わる)。

つなぎ方は2つに分かれます。出す側はD3から220Ωを通してLEDへ。(5V−2V)÷220Ω=13.6mAです。ATmega328Pデータシート の Absolute Maximum Ratings で1ピンあたりの上限は40mAです。ただしこれは「これを超えたら壊れる」値で、常用の目安は20mA以下。この電流ならトランジスタは要りません。測る側は5V─フォトレジスタ─A0─10kΩ─GNDの分圧で、明るいほどフォトレジスタの抵抗が下がってA0の電圧が上がります。

分圧の上下を逆にすると、明るくするほどA0の値が下がります。PIDは「値が足りないからもっと明るく」と動くため、明るくするほど値が下がる状態では際限なく出力を上げ続けて発散します。5V側がフォトレジスタ、GND側が10kΩです。
LEDにも向きがあります。足の長いほう(アノード)を220Ω側へ。逆だと光りません。
LEDとフォトレジスタは向かい合わせにして、上から黒いテープか紙コップで覆ってください。部屋の照明が入ると、それがそのまま外乱になって毎回ちがう波形になります。
教科書の式をC言語にする
PIDの式は3つの項の足し算。連続系の式を、そのままC言語に置き換えます。
P項 Kp・e(t)
I項 Ki・∫e(t)dt
D項 Kd・de(t)/dt
kp * error
integral += error * dt;
(error - prev_error) / dt
積分は「足していく変数」、微分は「前回との差を時間で割る」だけです。∫と d/dt という記号が難しく見えるだけで、コードにすると2行です。
error は「偏差」のこと組み込みのコードで
error と書くと、ふつうは異常や戻り値のエラーコードを指します。ただしPID制御では、目標値と測定値の差そのものを e(t)=error と呼びます。式の e をそのまま変数名にしたもので、異常という意味はありません。Arduinoのライブラリマネージャに入っている定番の PID_v1 も
double error = *mySetpoint - input; と書いています。この記事も同じ名前に揃えました。読み替えずに済むからです。float pid_update(pid_t *p, float setpoint, float meas)
{
float error = setpoint - meas;
/* P項:偏差にそのまま比例 */
float p_term = p->kp * error;
/* I項:偏差を足していく。dtを掛けるので単位は「秒あたり」 */
p->integral += error * p->dt;
float i_term = p->ki * p->integral;
/* D項:前回からの変化。dtで割るので単位は「1秒あたり」 */
float d_term = p->kd * (error - p->prev_error) / p->dt;
p->prev_error = error;
float u = p_term + i_term + d_term;
/* 出せる範囲に丸める */
if (u > p->out_max) { u = p->out_max; }
if (u < p->out_min) { u = p->out_min; }
return u;
}
これが動く最小形。ただしこのままだと2つの問題が起きます。積分ワインドアップと微分キックで、どちらもあとの節で実測を出します。
制御周期をタイマ割り込みで作る
PID制御は「一定の間隔で回す」ことが前提です。式のdtが一定でないと、積分も微分も値が狂います。
loop()の中でdelay(10)を使うと、そのあいだの処理時間が加わって周期がばらつきます。タイマ割り込みで一定の周期を作り、割り込みではフラグを立てるだけにします。
const float DT_S = 0.01f; /* 制御周期 10ms */
volatile bool g_tick = false;
void timer1_begin_10ms(void)
{
noInterrupts();
TCCR1A = 0;
TCCR1B = 0;
TCNT1 = 0;
/* 16MHz / 256 = 62500Hz。10ms = 625カウント */
OCR1A = 625 - 1;
TCCR1B |= (1 << WGM12); /* CTCモード */
TCCR1B |= (1 << CS12); /* 分周256 */
TIMSK1 |= (1 << OCIE1A);
interrupts();
}
ISR(TIMER1_COMPA_vect)
{
g_tick = true; /* フラグを立てるだけ */
}
void loop(void)
{
if (!g_tick) { return; }
g_tick = false;
/* ここでPIDを1回まわす */
}
割り込み処理は短く終わらせます。中で文字を送ると、次の割り込みが来ても処理しきれず取りこぼします。フラグを立てるだけにして、実際の処理は
loop()側で行います。タイマ割り込みで周期を作る手順そのものは、タイマ割り込みで一定周期を作る手順で分周比の計算から扱っています。
間に合っているかは、自分で数えて確かめます。1周期ぶんの処理を終えた時点で次の割り込みフラグが立っていたら、間に合っていません。
if (g_tick) { g_overrun++; } /* 次のtickが来てしまった=間に合っていない */
ADCを8回読むのに約0.9ms、floatを7個整形するのに約1ms。8回読むのは平均のためです。ADCは一瞬の電圧を掴むので、1回読みだとPWMが点いている瞬間か消えている瞬間かをそのまま拾い、31kHzまで上げても測定値が±7%前後まで跳ねます。(🔴 この数字はステップ応答を作っている途中で見たもので、生ログを保存していません。操作量を固定したときの揺れなど、他の数字はすべて生ログから出しています)115200 baudでは1行41バイトの送信に3.56ms(周期の36%)かかり、余裕がありません。250000 baudなら1.64msで収まります。16MHzでは250000のほうが誤差0%で作れます(115200は誤差2.1%)。
この構成でのビルドはスケッチが8,780バイト(32,256バイト中27%)、グローバル変数が1,594バイト(2,048バイト中77%)でした。環境はArduino IDE 2.x、ボードは arduino:avr:uno です。
RAMが77%まで埋まっているのは、ステップ応答を1ms刻みで撮るための配列を持っているためです(350点×2バイト=700バイト)。Arduino IDEはここで「メモリが少なくなっています」と警告を出します。9つのパターンを動かすだけならこの配列は要りません。
ゲインを決める前に、制御対象を測る
ここが「ゲインの決め方」の入口です。Kpをいくつにするかは、制御対象がどれだけ反応するかで決まります。反応の大きさを知らずに数字を決めることはできません。
測るのは3つです。
- 静ゲインPWMを1上げると、測定値が何%上がるか
- むだ時間と時定数出力を変えてから、測定値が動きはじめるまでと、落ち着くまで
- 安定限界Kpをどこまで上げると、振動が止まらなくなるか
3つ目だけは計算で出せません。上げていって、振動した値を見つけるしかありません。そのかわり、これがいちばん確かな数字になります。
まず静ゲインです。LEDを消したときと全開にしたときの明るさを測れば出ます。
この装置で測れる明るさの範囲(実測)
0.48%
覆いをして外の光を遮った状態
44.67%
PWM 255
0.173%/カウント
(44.67−0.48) ÷ 255
この数字があると、目標値に必要な操作量が計算で出ます。目標25%なら (25−0.48) ÷ 0.173 = 142カウント。上限255に収まるので届きます。目標60%なら344カウント必要で、上限を超えるので絶対に届きません。ワインドアップの節ではこれを利用します。
2点で出した傾きは、その場の傾きではない
ところが、実際に操作量を142にしても25%にはなりません。本当は95カウントで25%に届きます。計算が1.5倍ずれています。
理由は、消灯と全開の2点を結んだ直線が平均の傾きでしかないことです。確かめるために、操作量を5カウントずつ上げながら明るさを測りました(各点で0.3秒待ってから16回平均、52点)。
| 操作量 | 明るさ | その場の傾き | 2点の平均(0.173)との比 |
|---|---|---|---|
| 25 | 8.29% | 0.312 %/カウント | 1.80倍 |
| 50 | 15.39% | 0.254 %/カウント | 1.47倍 |
| 100 | 25.72% | 0.172 %/カウント | 0.99倍 |
| 150 | 33.29% | 0.125 %/カウント | 0.72倍 |
| 200 | 39.41% | 0.110 %/カウント | 0.64倍 |
| 240 | 43.20% | 0.095 %/カウント | 0.55倍 |
一番急なところと一番なだらかなところで3.5倍違います。暗いほど1カウントの効きが大きく、明るくなるほど鈍ります。フォトレジスタの抵抗が光に対して対数的に変わるためで、明るさの読み方をどう作ってもこの性質は残ります。
同じ装置から3つの数字が出ます。どれが正しいかではなく、何に使うかで選びます。
| どの傾きか | 値 | どう測るか | 使いどころ |
|---|---|---|---|
| 2点を結んだ平均 消灯と全開 |
0.173 | 2回読むだけ | その目標に届くかどうかの確認 |
| 入れたステップのK Δ明るさ ÷ Δ操作量 |
0.211 | ステップを1本 | Ziegler-Nicholsの計算。整定則が前提にしているのはこれ |
| 動作点の接線 目標25%のあたり |
0.176 | 掃引52点 | 安定余裕の見積り。ループを一周する増幅率はこれで決まる |
🔒 最初に測るのは上の2つで足ります。3つ目(掃引52点)が要るのは、発振しそうで安定余裕を確かめたいときだけです。
いちばん危ないのが暗い側の0.312をそのまま使うことです。Kpが1.8分の1に出るので、P制御では偏差が大きく残ったまま「これがZiegler-Nicholsの値だから」と納得してしまいます。ステップを入れる場所は、実際に使う動作点をまたぐように選んでください。この記事の 0→128 は、終わりが27.45%で目標の25%をまたいでいます。
「振動していない」の基準線を先に取る
このあと波形を「振れ幅」で比べます。そのとき、何%なら振動していないと言えるのかが決まっていないと比べられません。
そこでPIDを止めて、操作量を固定したまま、制御と同じ10ms周期で2秒ぶん記録しました。
| 操作量 | 明るさ | 振れ幅(最大−最小) | 標準偏差 |
|---|---|---|---|
| 0(消灯) | 0.56% | 0.09% | 0.019 |
| 25 | 9.11% | 1.88% | 0.487 |
| 100 | 25.95% | 1.91% | 0.459 |
操作量を動かしていなくても、明るさは1.9%ぶん揺れています。ADCのばらつきとLEDの明滅です。消灯すると0.09%まで下がるので、揺れの出どころはLEDを点けていること自体だと分かります。
つまり振れ幅2%前後は「振動していない」と読みます。以降の波形はこの線と比べてください。
速いのは取り柄だが、そのままではPIDの見せ場が無い
操作量を0から128へ一段だけ上げて、明るさの立ち上がりを1ms刻みで350点記録しました。制御周期の10msでは2点しか入らないので、この測定だけ別のルーチンで撮っています。3回繰り返した平均が次の値です。
ステップ応答の実測(3回・操作量 0→128)
0.211%/カウント
±0.003。0.48%→27.45%を128で割った
測れない
接線の切片が −4.4±0.4ms。0とみなす
24.7ms
±1.4(接線法)。63.2%到達なら22.7±2.4ms
むだ時間の切片がマイナスに出るのは、立ち上がりが速すぎて測れないという意味です。LEDは電流を流した瞬間に光り、フォトレジスタもすぐ追いつくので、装置そのものの遅れはゼロと考えてかまいません。接線法と63.2%到達が24.7msと22.7msでほぼ一致しているのは、この波形が素直な1次遅れに近いためです。
制御周期10msに対して時定数が24.7ms。時定数のなかに2.5サンプルしか入っていません。装置が速すぎて、ゆっくり近づく・行き過ぎる・戻ってくるというゲインを調整して初めて見える動きが出てきません。(🔴 むだ時間を入れない状態での安定限界は測っていません。ここは「速すぎて見えない」という見込みでむだ時間を足した、という設計の理由です)
一方で、実際の制御対象には必ずむだ時間があります。センサの通信にかかる時間、アクチュエータが動きはじめるまでの時間、それに演算そのものの時間。この記事ではそれを再現するため、測定値を5サンプル(50ms)遅らせてからPIDへ渡しています。以下の波形はすべてこの条件です。
🔒 このため、この記事の結論は「むだ時間が支配的な制御対象での話」です。L/Tが1より小さい相手なら、Dの効き方は変わります。
/* むだ時間:測定値をN サンプル遅らせてからPIDへ渡す */
float dead_apply(float v, int n)
{
if (n <= 0) {
return v; /* 遅らせない */
}
if (n >= DEAD_MAX) {
n = DEAD_MAX - 1; /* バッファからはみ出さない */
}
g_dead_buf[g_dead_idx] = v;
g_dead_idx = (g_dead_idx + 1) % DEAD_MAX;
int k = (g_dead_idx - 1 - n + DEAD_MAX * 2) % DEAD_MAX;
return g_dead_buf[k];
}
むだ時間L=50ms、時定数T=24.7ms、静ゲインK=0.211が分かれば、Ziegler-Nicholsのステップ応答法でゲインの出発点が計算で出ます。
まず
a = K × L ÷ T = 0.211 × 0.050 ÷ 0.0247 = 0.426。ここから3つが決まります。Kp = 1.2 ÷ a = 2.82 → 2.8Ti = 2L = 0.10秒 なので Ki = Kp ÷ Ti = 28Td = 0.5L = 0.025秒 なので Kd = Kp × Td = 0.070係数の出典:Ziegler, J. G. & Nichols, N. B.「Optimum Settings for Automatic Controllers」。Transactions of the ASME 第64巻・1942年・759〜768ページ。
PI・PIDの波形は、この3つをそのまま入れて測ったものです。手で詰めていません。この出発点が当たっているかどうかも、測った結果で出します。
安定限界を実測して、計算と突き合わせる
計算で出た2.8が妥当かどうかは、どこまで上げたら振動するかを知らないと判断できません。そこでKiとKdを0にしたP制御だけで、Kpを0.25から8まで12段階に振りました。

Kp=5.5までは振れ幅3.03%で、操作量を固定したときの1.9%とほとんど変わりません。ところがKp=6.0で21.74%へ跳び、周期129msの振動が立ちます。0.5上げただけです。
偏差のほうは、この間ずっとなだらかに減り続けています(Kp=5.5で8.81%、Kp=6.0で8.91%)。偏差だけを見ていると、跳ねたことに気づけません。
境界がここまで鋭いのは、むだ時間が支配的な系の特徴です。時定数が支配的な系なら、振れ幅はもっとなだらかに増えます。
Ziegler-Nicholsにはもう1つ、限界感度法という決め方があります。振動しはじめるゲインKuとその周期Tuから直接出す方法です。
いま測った
Ku=5.75(5.5と6.0の中を取った)とTu=129msを入れると Kp = 0.6 × Ku = 3.45。ステップ応答法が出した2.82に対して23%違います。どちらかが間違っているのではなく、整定則はもともとこの程度の精度のものです。
🔒 ここで確かめたかったのは、2.8が安定限界5.75の半分(49%)にあたることです。2つの整定則の値が一致するかどうかではありません。この余裕が確認できたので、以降はKp=2.8で進めます。
ループを一周する増幅率は
Kp × 静ゲイン です。ここで使うのは、さきほどの3つのうち動作点の接線(0.176)のほう。5.75 × 0.176 = 1.01。振動しはじめた点で、ちょうど1になりました。「一周して戻ってきた信号が元より小さければ収まり、大きければ育つ」という理屈と、実測が合っています。2点平均の0.173で計算しても1.00で、ほぼ同じ。いっぽうステップ応答のK(0.211)で計算すると1.21になり、1から2割ずれます。安定を見るときは動作点の接線という使い分けが、ここで数字として出ました。
この記事で使う6つの指標
以降の波形は、次の6つの数字で比べます。定義を先に決めておかないと、同じ波形から違う結論が出ます。
| 指標 | 定義 | なぜこの定義か |
|---|---|---|
| 最終の明るさ | 最後1秒の平均 | 1点だけ見るとノイズを拾う |
| 偏差 | 目標値 − 最終の明るさ | 残ったズレの大きさ |
| 行き過ぎ | 明るさの最大値 − 目標値(平滑しない生の値) | 平滑すると、振動している波形で信号ごと消えることがある(下の注意) |
| 振れ幅 | 後半の明るさの最大 − 最小 | 収まったのか、振動し続けているのか |
| 飽和 | 操作量が255に張り付いていた時間の割合 | 制御できていない時間の長さ |
| 復帰 | 目標値を戻してから、移動平均が目標の±5%の帯に入り、0.2秒とどまるまで | 「初めて帯を跨いだ時刻」だとノイズ1点で決まってしまう |
この記事は最初、行き過ぎを5点(50ms)の移動平均で測っていました。ノイズ1点に振り回されないための処置です。ところが、あとで出てくるPIDの波形は41.4Hz(周期24.2ms)で振動していて、50msの窓がちょうど2.1周期ぶんでした。山と谷が窓の中で打ち消し合います。
同じ4回のログを両方の定義で測ると、こうなります。
| 行き過ぎの測り方 | PI(4回) | PID(4回) | PIDのばらつき |
|---|---|---|---|
| 生値 | 1.73 ± 0.15% | 16.35 ± 0.12% | 平均の0.7% |
| 50ms平滑 | 0.77 ± 0.11% | 6.58 ± 1.30% | 平均の20% |
平滑すると16.35%が6.58%へ、6割減ります。減ったのはノイズではなく見たかった振動そのものです。しかも4回のばらつきが0.12から1.30へ11倍に悪化しています。信号を削ると、再現性まで落ちます。
振動と窓のずれがもっと小さかったときのデータでは、平滑値が「差なし」に見えてしまい、「Dは行き過ぎを悪化させていない」という逆の結論を一度載せました。
🔒 平滑の窓が、振動の周期の整数倍に近くないかを確かめてください。以降この記事は行き過ぎを生値で出し、平滑値と食い違ったら波形を見ることにしています。
Kpを振る:小さいと届かない、大きいと振動する
まずP制御だけで動かします。変えるのはKpだけで、他は何も変えていません。

float p_term = 1.0f * error;
読み取り:明るさは7.33%で止まり、目標25%に対して17.67%足りない。振れ幅は1.71%で、操作量を固定したときの1.9%より小さい。まったく振動していない。P制御では偏差がゼロになると操作量もゼロになるため、偏差が残ったところで釣り合ってしまう。
float p_term = 8.0f * error;
読み取り:偏差は17.67%から6.75%まで減った。そのかわり振れ幅28.96%の振動が止まらない。固定時の1.9%に対して15倍で、目標25%を挟んで上下を振り切っている。むだ時間の50ms前の値を見て操作しているので、行き過ぎたことに気づくのが遅れて往復する。
Kp=1で17.67%、Kp=8で6.75%。上げれば減りますが、ゼロにはなりません。P項は偏差に比例するので、偏差がゼロになった瞬間に操作量もゼロになるためです。
しかもKpを0.25から8まで振った実測のとおり、Kpを上げ切る前に振動が始まります。偏差を消すためにKpを上げ続ける道は、途中で塞がっています。ここを埋めるのがI項です。
Kiを足す:残った偏差が消える
Kiを足すと、P制御で17.67%残っていた偏差が−0.05%まで消えます。積み上げた差が、Pだけでは届かなかった分を埋めるからです。
赤はP制御だけ(Kp=1)、青はPI制御(Kp=2.8・Ki=28)。KiだけでなくKpも変わっています。P制御のKpをそのままにしてKiを足すと、遅すぎて5秒では何も起きないからです。
なので、この図から読み取れるのは「PではKpをどう選んでも偏差が残り、Iを入れると消える」までです。「Kiだけの効果」ではありません。Kpの効果はひとつ上の節で、Kp以外を止めて測っています。
🔒 この図から先の波形は、積分ワインドアップ対策と測定値微分を最初からONにして撮っています。どちらも後の節で中身を説明します。最初に出した最小形のコードそのままではこの波形にならないので、再現するときは全体のコードを使ってください。

p->integral += error * p->dt;
float i_term = 28.0f * p->integral;
読み取り:最終の明るさは25.05%で、偏差−0.05%。P制御で17.67%残っていたズレが、ほぼゼロになった。下段を見ると、積分項が93.7まで育って偏差を埋めている。偏差が残っているあいだ積分は足され続け、ゼロになったところで止まる。これがI項の働き。振れ幅は2.65%で固定時の1.9%に近く、操作量が上限に張り付いた時間は0%。余裕をもって制御できている。
Kdを足す:収まっていた制御が振動に変わった
教科書では、D項は行き過ぎを抑える働きをすると説明されます。同じKp・Kiのまま、Kdだけを0から0.070にして測りました。この0.070は、さきほどZiegler-Nicholsの式が出した値そのものです。

行き過ぎは 0.65% → 5.92%。減るどころか9.1倍になりました。教科書が言う効果は、この装置では出ません。
行き過ぎ以外も、まとめて悪くなりました。振れ幅 2.65% → 30.06%、操作量の飽和 0% → 37%、最終の明るさは目標を2.64%超えたところで落ち着きました。PIは目標に張り付いて動かなくなったのに、PIDは5秒間ずっと上下し続けています。4つの指標が全部悪くなりました。
振れ幅30.06%は、Kp=8まで上げたとき(28.96%)より大きい値です。Kdを0.070足しただけで、安定限界を1.4倍超えたときより荒れました。
この4つは同じ壊れ方を別の角度から写しています。行き過ぎは一番高く行った点、振れ幅は最後まで揺れているかどうか、飽和は制御できていない時間の長さ、偏差は目標にいられたかどうか。
1つだけ見ていると、その指標の測り方の癖に引っかかったときに気づけません。実際この記事も一度そこで転んでいます(平滑化で、見たかったものが消えることがある)。
理由はむだ時間にあります。D項は50ms前の変化に反応するので、押すタイミングがずれます。今回の装置はむだ時間50ms・時定数24.7msで、L/T=2.0の「むだ時間が支配的」な系です。ずれた向きに押す力を足したので、ループが自分で振動を保つようになりました。
この整定則が想定しているのはL/Tが0.1〜1程度の系で、L/T=2.0はその外側です。そのためKpとKiは当たり(偏差−0.05%・飽和0%で収まった)、Kdだけが外れたように見えます。
式が出した値を疑わずに入れると、こうなります。計算は出発点であって、答えではありません。そして外れていることは、測らなければ分かりません。
「Dを足せば行き過ぎが減る」は、遅れが時定数で決まる系での話です。むだ時間が支配的な系では、D項は古い情報に反応して悪化させます。自分の制御対象がどちらなのかは、むだ時間と時定数を測ってL/Tを出せば分かります。
🔴 ただし、この装置で悪さをしていた本当の原因は別にあります。Kdを0.070のままにして、微分にローパスを入れるだけで振れ幅が30.58%から2.30%まで収まります。ここは後の節で覆るので、「Dはだめ」で読むのをやめないでください。
ただし、ここまではすべて1回ずつの測定です。1回の測定でゲインを決めないで同じ条件を4回ずつ繰り返し、この4つの差が測定のばらつきでは説明できないことを確かめています。
積分ワインドアップ:届かない目標を出したあと
ここがPID制御でいちばん実害の出るところです。目標値が装置の能力を超えていると、偏差がいつまでも残るため積分が際限なく育ちます。
先ほど計算したとおり、この装置で目標60%を出すには344カウント必要で、上限255では届きません。LEDを全開にしても44.67%までしか出ないので、どうやっても届かない目標です。2.0秒でそこへ上げ、5.0秒で元へ戻します。

p->integral += error * p->dt; /* 無条件に足す */
読み取り:届かない目標のあいだ、積分項が1,366.7まで育った(操作量の上限は255)。5.0秒で目標を戻しても、この溜まった分が抜けるまで出力は上限に張り付いたまま。目標に戻ってくるまで2.15秒かかり、測定時間の45%を飽和したまま過ごした。
float i_cand = p->ki * (p->integral + error * p->dt);
float u_cand = p_term + i_cand + d_term;
/* 出せる範囲を超えていて、しかもさらに押し込む向きのときだけ、足さない */
bool pushing_further = (u_cand > p->out_max && error > 0.0f) ||
(u_cand < p->out_min && error < 0.0f);
if (!pushing_further) {
p->integral += error * p->dt;
}
読み取り:積分項は213.3で頭打ちになった(対策なしの6.4分の1)。目標を戻すと0.73秒で追従。対策なしの2.15秒に対して2.9分の1。飽和していた時間は45%から4%へ減っており、こちらのほうが差は大きい。コードの差はif文1つ。
出せない量を積んでも意味がなく、あとで抜くのに時間がかかるだけなので、そもそも足しません。ただし「飽和したら積分しない」だけでは足りません。目標を下げたあとは、偏差が反対向きになって積分を減らそうとしています。そこまで止めると、溜まった分がいつまでも抜けません。止めるのは、さらに押し込む向きのときだけです。この方式は条件付き積分と呼ばれます。積分値そのものに上限を設ける方法もありますが、上限をいくつにするか決める必要があり、こちらは決める値がありません。
D項を使えるようにする:ローパスと微分キック対策
ここまでD項は悪者でしたが、直し方は2つあります。ローパスフィルタと、微分の元を偏差から測定値へ変えることです。
この2つは直す相手が違います。まとめて入れると、どちらが直したのか分からなくなります。1つずつ入れて測りました。目標値は2.5秒で15%から32%へ変えています。
まずローパスを入れる
D項は測定値の変化を見るので、センサのノイズをそのまま増幅します。それなら、微分した値を平らにしてから使えば済むはずです。
/* 1次のローパス。alphaが小さいほど平らになる(この記事は0.1) */
p->d_filtered += p->d_lpf_alpha * (d_raw - p->d_filtered);
float d_term = p->d_filtered;

目標を変える前(0〜2.5秒)の振れ幅は 30.58% → 2.30%。13分の1です。操作量の飽和は 29% → 0%、最終の偏差は 3.86% → −0.02%。対策なしは目標へ戻ってこられませんでしたが、対策ありは 0.51秒 で追従します。
Kdは0.070のまま、むだ時間も50msのまま。Dを足したら壊れたあの波形と、ゲインは1つも変えていません。
つまり壊していたのは生の微分がノイズを増幅していたほうで、D項そのものではありませんでした。Dを捨てる前に、まずローパスを試してください。
1次ローパスの
alphaは、時定数に直すと dt ÷ alpha。この記事は 0.010 ÷ 0.1 = 0.1秒 です。むだ時間50msの2倍、微分時間Td=25msの4倍にあたります。目安は微分時間Tdの2〜5分の1の帯域、つまりフィルタの時定数を
Tdの2〜5倍に取ることです。平らにしすぎると、Dが見ているのが古い変化になり、D項を入れた意味がなくなります。残るのは、目標値を変えた瞬間だけ
ローパスで振動は止まりました。それでも1か所だけ跳ねます。D項を偏差から計算していると、目標値を変えた瞬間に偏差が段差になり、微分が跳ね上がります。

float d_raw = p->kd * (error - p->prev_error) / p->dt;
p->d_filtered += p->d_lpf_alpha * (d_raw - p->d_filtered);
読み取り:目標変更の直前は−0.3だった微分項が、変えた瞬間に+12.6へ跳ねた。測定値はまだ動いていません(15.65%)。目標値を変えただけで操作量が押されています。
ローパスが無ければ、跳ねる量は Kd × 段差 ÷ dt = 0.070 × 17 ÷ 0.010 = 119 カウント。上限255の半分近くです。実測が12.6で収まっているのはローパスが最初の1回で10分の1に薄めているためで、キックそのものが無くなったわけではありません。
/* 目標値の変化に反応しないよう、測定値の変化だけを見る(符号は反転) */
float d_raw = -p->kd * (meas - p->prev_meas) / p->dt;
p->d_filtered += p->d_lpf_alpha * (d_raw - p->d_filtered);
読み取り:目標を変えた瞬間の微分項は−0.7。直前(−0.4)から何も起きていません。目標値は微分に入っていないので、段差そのものが存在しないからです。
そのあと明るさが実際に上がっていくと、微分項は−6.0まで下がります。こちらは測定値が動いたことへの反応で、跳ねではありません。D項に本来してほしい仕事です。
変えたのは微分の元だけ。ローパスも、Kd=0.070も、むだ時間50msも同じです。
| 変えたところ | 直るもの | 実測 |
|---|---|---|
| ローパスを入れる | ノイズの増幅による振動 | 振れ幅 30.58% → 2.30% と 飽和 29% → 0% |
| 微分の元を測定値にする | 目標変更の瞬間のキック | 微分項 +12.6 → −0.7 |
片方だけでは片方しか直りません。ローパスだけ入れると振動は止まりますが、目標を変えるたびに操作量が押されます。測定値微分だけ入れるとキックは消えますが、ノイズは増幅されたままです。両方入れるのが既定の形で、全体のコードはそうなっています。
そして、ここまで来ると「Dを足したら壊れた」の見え方が変わります。Kdは0.070のまま、ローパスを入れただけで振れ幅30.58%が2.30%まで収まりました。「むだ時間が支配的だからDはだめ」ではなく、「生の微分をそのまま使ってはいけない」が正しい教訓です。
偏差は「目標値−測定値」。目標値が一定なら、偏差の変化は測定値の変化の符号違いになります。目標値が動かない場面では、両者は同じ動きをします。違うのは目標値を変えた瞬間だけで、そこで跳ねないのが対策ありです。
Kdを上げすぎると、微分項がノイズを増幅する
Kdを10倍にすると、微分項もちょうど10倍になります。D項は測定値の変化を見ているので、ノイズも同じ倍率で増えます。

float d_raw = -0.70f * (meas - p->prev_meas) / p->dt;
読み取り:青(Kd=0.70)は−2,017.7まで振れている。出せる操作量の上限が255だから絶対値で7.9倍。赤(Kd=0.070)は平らに見えますが、こちらも−202.3まで振れています(上限の79%)。青のスケールが大きすぎて潰れて見えているだけで、赤もKdを足したら振動に変わった節で振動を起こしていた当人です。Kdを10倍にしたら微分項もちょうど10.0倍になっており、ノイズがそのまま増幅されている。
操作量が上限に張り付いた時間は37%から49%へ増えた。いっぽう明るさの振れ幅は30.06%から31.21%までしか増えていない。すでに上下を振り切っているので、振れ幅ではもう差が出ません。悪化は飽和の側に出ます。
ローパスを入れると振れ幅が13分の1になるのを見たあとだと、「ではKdは何でもいいのか」と思えます。そうではありません。
ローパスが平らにするのは速い揺れで、Kdが決めるのは微分項そのものの大きさです。上の実測でKd=0.70の微分項が−2,017.7まで出たのは、ノイズではなくKdが10倍だからで、平らにしても大きさは残ります。
🔒 ローパスが引き受けるのは「生の微分を使わない」ところまでです。Kdをいくつにしてもよくする道具ではありません。Td=Kd÷Kp がむだ時間の半分あたりに収まっているかを、別に確かめてください。
(🔴 この節のKd=0.70はローパス無しで測っています。ローパスを入れたKd=0.70は測っていませんので、「ローパスでどこまで救えるか」は本記事では答えていません)
1回の測定でゲインを決めない
ここまでの数値は、すべて1回ずつの測定です。装置に触っていなくても、測るたびに値は動きます。その動きより小さい差は、差ではありません。
PIとPIDを4回ずつ、交互に測りました。順番の影響が出ないよう、PI→PID を1組として4回繰り返しました。PIを4回続けてからPIDを4回にすると、その間に装置がずれたぶんが差に化けます。
| 指標 | PI(Kd=0) | PID(Kd=0.070) | 判定 |
|---|---|---|---|
| 振れ幅 | 3.27 ± 0.34% | 30.24 ± 0.24% | 4回の範囲が重ならない → 差と言える |
| 偏差 | −0.04 ± 0.02% | −3.01 ± 0.08% | 4回の範囲が重ならない → 差と言える |
| 飽和 | 0.00 ± 0.00% | 33.78 ± 6.04% | 4回の範囲が重ならない → 差と言える |
| 行き過ぎ(生値) | 1.73 ± 0.15% | 16.35 ± 0.12% | 4回の範囲が重ならない → 差と言える |
| (参考)同じ行き過ぎを 50ms平滑して測ると |
0.77 ± 0.11% | 6.58 ± 1.30% | 差は残るが、ばらつきが11倍に悪化 |
上の4行は、Kdを足すとどれも悪くなることを別の角度から示しています。いちばん下の行だけが、同じ「行き過ぎ」を測り方だけ変えたものです。ここがこの節でいちばん大事なところなので、順に見ていきます。
4つとも4回の範囲がまったく重なりませんので、Kdによる悪化だと言えます。振れ幅は PI 2.98〜3.76% に対して PID 29.98〜30.50%で、間が26%空いています。行き過ぎも PI 1.64〜1.95% に対して PID 16.18〜16.45% で、14%空いています。
ばらつきの小ささにも意味があります。PIDの振れ幅は4回とも30.0〜30.5%の中に入りました。ばらつきが平均の0.8%しかないので、Kdを足すと荒れること自体は、この条件なら毎回起きます。
Kdの節で使った数字は、どれも1回ずつの測定です。それを上の4回の範囲と並べると、こうなります。
| 指標 | 1回だけ測った値 | 4回の範囲 | |
|---|---|---|---|
| PIDの振れ幅 | 30.06% | 29.98〜30.50% | 入っている |
| PIの偏差 | −0.05% | −0.06〜−0.02% | 入っている |
| PIDの偏差 | −2.64% | −3.08〜−2.91% | 🔴 外れている |
| PIの振れ幅 | 2.65% | 2.98〜3.76% | 🔴 外れている |
4つのうち2つが、自分の4回の範囲から外れました。装置もゲインも変えていません。
これが「1回の測定でゲインを決めない」の実物です。Kdを足すと荒れるという結論は変わりませんが、
「偏差が2.64%だった」という1回の数字を持ち出して他と比べると、それだけで0.4%ずれます。
🔒 1回の値は波形を読むために使い、比較には範囲を使ってください。
同じ行き過ぎを50msで平滑して測ると、PIDの16.35%が6.58%まで減ります。装置は何も変えていません。
PIDの振動は41.4Hz=周期24.2msで、50msの窓がちょうど2.1周期ぶん。山と谷が窓の中で相殺されます。
ばらつきを見ると、もっとはっきりします。生値は 16.35±0.12(ばらつきは平均の0.7%)、平滑値は 6.58±1.30(平均の20%)。信号を削ると、値が小さくなるだけでなく再現性まで落ちます。
🔴 この装置ではまだ差が残りましたが、振動の周期と窓がもう少し噛み合うと、差そのものが消えます。実際にそれが起きたときのことは指標を決めた節に書きました。平滑して値が小さくなったら、ノイズが取れたのか信号が消えたのかを波形で確かめてください。
ゲインを変えて1回測り、良くなったから採用する。この手順では、実際にはばらつきを見ているだけということが起こります。
車載の制御でも、ゲインを1つ変えて1回走らせて判断することはありません。同じ条件で複数回取り、ばらつきの幅を出したうえで比べます。比べるのは「平均とばらつき」で、1回の値ではありません。
この結論が当てはまる範囲
この記事の数字がそのまま使えるのは、L/T=2.0、つまりむだ時間が時定数の2倍ある相手までです。手順は他の対象へ持っていけますが、数字は持っていけません。
波形はすべてArduino Uno R3の実測です。制御周期10ms、シリアル250000 baudでCSVを吐かせ、PC側で保存しています。
指標の定義はこの記事で使う6つの指標のとおりで、平滑化の点数(5点)・帯の幅(±5%)・とどまる時間(0.2秒)まで固定してあります。繰り返し測定は、PIとPIDを1組として4回、交互に取りました。
ビルドはArduino IDE 2.x、ボードは arduino:avr:uno。スケッチが8,780バイト(32,256バイト中27%)、グローバル変数が1,594バイト(2,048バイト中77%)です。
較正は測定の前と後の2回取り、消灯0.48%→0.59%・全開44.67%→44.92%でした。この測定のあいだに装置は0.3%しかずれていません。すべての波形が同じ条件だと言える根拠です。
4回ずつ測った表の±は1回ごとのばらつき(標準偏差)です。平均どうしを比べるときに本来使うのは、これを回数の平方根で割った平均のばらつき(標準誤差)で、4回なら半分になります。
つまり標準偏差で比べるのは厳しい側の見方です。この記事はどちらで見ても結論が変わらない(振れ幅は標準偏差比79倍・標準誤差比129倍)ので厳しい側を載せました。差が小さいときは、どちらで判定したかで結論が変わります。
この装置はL/T=2.0、つまりむだ時間が時定数の2倍ある系です。しかもそのむだ時間はソフトで作ったもので、実物のLEDとフォトレジスタはほとんど遅れません。
「生のD項を足したら悪くなった」はこの条件での結果です。L/Tが1より小さい相手(動きはじめは速いのに、落ち着くまでが長い相手)では、D項の効き方は変わります。手順は流用できますが、数字は流用できません。
あなたの制御対象に読み替える
この記事は光でやりましたが、測る量と動かす量が決まっていれば、中身は同じです。自分の対象に置き換えるときは、次の6つが何になるかを先に決めてください。
| 決めるもの | この記事(光) | モータの回転数なら | ヒータの温度なら |
|---|---|---|---|
| 目標値 SP | 明るさ 25% | 目標の回転数[rpm] | 目標の温度[℃] |
| 測定値 PV | フォトレジスタの分圧をADCで読む | エンコーダのパルスを数えて回転数にする | 熱電対やサーミスタを読む |
| 操作量 MV | LEDへのPWM 0〜255 | ドライバへのPWM 0〜255 | ヒータのPWM、またはSSRのON比率 |
| 静ゲイン K | 0.211 %/カウント 動作点25%では0.17前後 |
◯◯ rpm/カウント | ◯◯ ℃/カウント |
| むだ時間 L | ソフトで入れた50ms | ドライバの応答と、回転数を平均する時間 | 熱が伝わって温度計に届くまで |
| 時定数 T | 24.7ms | 慣性の大きさで決まる | 熱容量の大きさで決まる |
静ゲインの単位は対象ごとに変わります(%/カウント、rpm/カウント、℃/カウント)。それでも
a = K × L ÷ T と Kp = 1.2 ÷ a はそのまま使えます。Kpの単位が「カウント / 測定値の単位」になるだけで、式の形は変わりません。コードも変わりません。全体のコードの
float に入る数字の意味が変わるだけです。この記事のKp=2.8をそのまま持っていっても動きません。KもLもTも対象ごとに違うからです。
持っていけるのは順番のほうです。ゲインを決める手順の7段をそのまま自分の対象へ当ててください。
特にL/Tを先に出すこと。この記事はL/T=2.0で生のD項が悪さをしましたが、L/Tが小さい対象ならDは素直に働きます。自分がどちら側にいるかで、Dに使う時間が決まります。
ゲインを決める手順:どこから手を付けるか
ここまでの実測を、手を動かす順に並べ直します。この順でやると、うまくいかなかったときにどこへ戻ればいいかが分かります。
- 静ゲインを測る操作量を0と最大にして、測定値がどこからどこまで動くか。この記事なら 0.48%〜44.67%。ただしゲインの計算には、使う動作点でステップを入れて測った傾きを使う(この記事は0.211)
- むだ時間と時定数を測る操作量にステップを入れて、動きはじめるまで(L)と落ち着くまで(T)。L/Tが1より大きければ、Dは生のままでは使えないと思っておく
- 計算で出発点を出すa=K・L÷T から Kp=1.2÷a、Ti=2L、Td=0.5L。ここはまだ出発点で、答えではありません
- Ki=0・Kd=0にしてKpだけ振る振動しはじめる値を見つけ、その半分から3分の1に戻します。偏差は残りますが、それはIで埋めます
- Kiを足すTi=Kp÷Ki。むだ時間の3〜4倍から始め、短くしていって振動が出たら戻します
- Kdは生のまま試さないTd=Kd÷Kpがむだ時間の半分あたりを試します。ただし最初からローパスと測定値微分を入れてください。この記事では、生の微分だと振れ幅が30.58%まで荒れたのに、Kdを変えずにローパスを入れるだけで2.30%まで収まりました
- 各段階で複数回測る差がばらつきより小さいなら、その変更に意味があるかは分かりません。指標も1つに絞らないでください
Kp・Ki・Kdを一度に変えて良くなっても、どれが良くしたのか分かりません。次に条件が変わったとき、また最初から探すことになります。1回に1つだけ変えると、変更と結果が1対1で残ります。
この記事の10パターンも、比べている図はすべて1か所だけを変えた組です。例外は1つで、図のそばに断ってあります。図6はP制御(Kp=1)とPI制御(Kp=2.8・Ki=28)を比べているのでKpも動いています。
🔴 この記事も一度そこを外しました。微分キックの図は当初、微分の元とローパスを同時に変えた2本を並べていて、差がどちらから来たのか分けられませんでした。いまは3本にして、1回に1つだけ変わるようにしてあります。
Ti・Td・制御周期を「時間」で決める
ここまでの実測から、実際に手を動かすときの勘所をまとめます。
制御周期は時定数の何分の1にするか
目安は時定数の5分の1から20分の1です。細かくすれば良くなるというものではありません。粗すぎると制御が追いつかず、細かすぎると1周期あたりの変化がノイズに埋もれます。
この装置は時定数が24.7ms、制御周期が10msなので、時定数のなかに2.5サンプルしか入っていません。かなり粗い部類です。
ただしこの記事の波形を振動させているのは、足した50msのむだ時間のほう。遅れが50msでサンプリングが10msなら、支配しているのはむだ時間です。
この記事の10msは、シリアルでCSVを送る時間から決まっています。1行41バイトを250000 baudで送るのに1.64ms、ADCを8回読むのに0.9ms、floatを7個整形するのに約1ms。合計で3〜4msなので、5ms周期にすると余裕がなくなります。
制御周期は、1周期でやることが全部終わる範囲で決めます。間に合っているかは
g_overrunで数えられます。KiとKdは「時間」で考えると決めやすい
Ki・Kdの数字は、そのままでは大きいのか小さいのか分かりません。時間に直すと、むだ時間や時定数と直接くらべられます。
この記事では 2.8 ÷ 28 = 0.1秒
むだ時間50msの2倍。「溜まった偏差を消すのにこれくらいかける」という時間。短すぎると振動し、長すぎると偏差が残る時間が延びる。
この記事では 0.070 ÷ 2.8 = 25ms
むだ時間50msのちょうど半分。「何秒先を読むか」という時間。この25msは生の微分では外れ、ローパスを入れると当たりだった。同じ時間でも、何を微分するかで結果が変わる。
Ziegler-Nicholsのステップ応答法は、この2つをむだ時間Lから直接出します。PI制御ならTi=3.3L、PID制御ならTi=2L・Td=0.5L。時間のほうはLだけで決まります。ただしKi=Kp÷Ti、Kd=Kp×Td なので、ゲインに直すにはKpが要り、Kpを出すには静ゲインKと時定数Tも要ります。3つとも測らないと数字にはなりません。
そしてこの記事で見たとおり、出た値が使えるかどうかは別の話です。Ti=0.1秒はそのまま当たりましたが、Td=25msは微分の作り方を直すまで使えませんでした。
動作点が変わると、ゲインも変わる
静ゲインを52点で測った表のとおり、1カウントの効きは動作点で3.5倍変わります。変わるのは静ゲインだけではありません。装置には一切触らず、ステップを入れる場所だけを変えて確かめました。
| ステップ(明るさの変化) | 静ゲイン K | 時定数 T | a = K・L÷T |
|---|---|---|---|
| 暗い側:操作量 0 → 128 0.48% → 27.45% |
0.211 %/カウント | 24.7 ms | 0.427 |
| 明るい側:操作量 128 → 255 30.33% → 44.49% |
0.111 %/カウント | 12.1 ms | 0.459 |
| 比 | 1.9分の1 | 約半分 | 1.07倍 |
明るい側では、静ゲインが半分になり、時定数も半分になりました。フォトレジスタ(CdS)は明るいほど反応が速く、そのぶん1カウントの効きが鈍ります。
a = K・L ÷ T なので、KとTが両方とも半分になれば a はほとんど動きません。実測でも0.427と0.459で、差は7%。計算で出るKpは2.82と2.61で、ほぼ同じです。ただし、これは今回たまたま打ち消し合っただけです。「動作点は気にしなくていい」という意味ではありません。モータなら、負荷が増えると静ゲインは下がりますが時定数は伸びるので、同じ向きには動かず、aは大きく変わります。
🔒 KとTは別々に測って、両方を記録してください。aだけを見ていると、中で何が起きたのか分かりません。
センサの位置をずらした、覆いを足した、部屋の明るさが変わった。どれも静ゲインを変えます。ゲインだけを持ち回して「前は動いたのに」となるのは、たいていこれです。
安全側に倒すなら、いちばんゲインが高くなる動作点で調整します。そこで安定していれば、他の動作点でも発振しません。
安定限界の探し方
Kpを上げていって振動しはじめる値を見つけ、そこから戻します。このとき、KiとKdは0のままにしてください。Iが入っていると、振動しているのかIが遅れて追いついているのかが見分けられません。

上の実測では、Kp=5.5までは振れ幅3.03%で静かなのに、Kp=6.0で21.74%へ跳びました。0.5上げただけです。実用のKpは、この値の半分から3分の1に取ります。この記事の2.8は安定限界の49%にあたります。
同じ実測で、偏差はKp=5.5で8.81%、Kp=6.0で8.91%。ほとんど動いていません。「まだ偏差が減るから、もう少し上げよう」と偏差だけを見て上げると、限界を越えたことに気づけません。
判定に使うのは振れ幅で、比べる相手は操作量を固定したときの振れ幅です。この記事なら1.9%。ここを超えて増えはじめたら、それは制御が作った揺れです。
Kpを少しずつ上げたとき、じわじわ振れ幅が大きくなるなら時定数が支配的、少し上げただけで止まらなくなるならむだ時間が支配的です。前者はDを足す価値があります。後者はDを足しても、この記事のように生の微分では悪化します。
この装置は0.5上げただけで7倍になったので、むだ時間が支配的な側とはっきり出ました。L/T=2.0という測定とも合っています。
ただし、これは目安です。確かめるならL/Tを測ってください。Lは操作量にステップを入れてから測定値が動きはじめるまで、Tはそこから落ち着くまでです。
ループを一周する増幅率はKpと静ゲインKの掛け算です。この装置のKは、暗い側で0.211・明るい側で0.111と1.9倍動きます。そのぶん振動しはじめるKpも動きます。
安全側に倒すなら、いちばんゲインが高くなる動作点で限界を探してください。この装置なら暗い側です。そこで安定していれば、明るい側でも発振しません。
全体のコード
実測に使ったPID本体です。10パターンはすべて、このコードのゲインとフラグを変えただけで、本体は1行も書き換えていません。
初回の微分:1回目の呼び出しには前回値がありません。
prev_measが0のまま微分すると、起動した瞬間に微分項が跳ねます。微分キックを扱う記事が、起動時に自分で微分キックを作らないようstartedで止めています。飽和中の積分:単に「飽和したら積分しない」にすると、戻る向きの偏差まで積分しなくなります。そのため偏差の符号を見て、さらに押し込む向きのときだけ止めます(
pushing_further)。typedef struct {
float kp, ki, kd;
float dt; /* 制御周期[s]。一定であることが前提 */
float integral;
float prev_error;
float prev_meas;
float d_filtered; /* 微分項のローパス後の値 */
float d_lpf_alpha; /* 1.0でフィルタなし */
float out_min, out_max;
bool anti_windup; /* 飽和するなら積分しない */
bool deriv_on_meas; /* 測定値を微分する */
bool started; /* 初回の微分を0にするため */
} pid_t;
float pid_update(pid_t *p, float setpoint, float meas)
{
float error = setpoint - meas;
/* --- P項 --- */
float p_term = p->kp * error;
/* --- D項:偏差から取るか、測定値から取るか --- */
float d_raw;
if (!p->started) {
d_raw = 0.0f; /* 初回。前回値が無いので微分しない */
} else if (p->deriv_on_meas) {
d_raw = -p->kd * (meas - p->prev_meas) / p->dt;
} else {
d_raw = p->kd * (error - p->prev_error) / p->dt;
}
p->d_filtered += p->d_lpf_alpha * (d_raw - p->d_filtered);
float d_term = p->d_filtered;
ここまでがPとDです。Dはderiv_on_measで「偏差を微分」と「測定値を微分」を切り替え、そのあとローパスを通します。微分キックの2つの対策が、この数行に入っています。
残るI項は、足す前に「足したらどうなるか」を計算します。これが積分ワインドアップの対策です。
/* --- I項:足す前に、足した結果が出せる範囲か確かめる --- */
float i_cand = p->ki * (p->integral + error * p->dt);
float u_cand = p_term + i_cand + d_term;
bool saturating = (u_cand > p->out_max) || (u_cand < p->out_min);
bool pushing_further = (u_cand > p->out_max && error > 0.0f) ||
(u_cand < p->out_min && error < 0.0f);
/* 飽和していても、戻る向きの偏差なら積分してよい */
if (!(p->anti_windup && saturating && pushing_further)) {
p->integral += error * p->dt;
}
float i_term = p->ki * p->integral;
p->prev_error = error;
p->prev_meas = meas;
p->started = true;
float u = p_term + i_term + d_term;
if (u > p->out_max) { u = p->out_max; }
if (u < p->out_min) { u = p->out_min; }
return u;
}
ArduinoのD3の既定のPWMは約490Hzです(Arduino Docs「analogWrite()」)。この周波数だとフォトレジスタが明滅を拾い、測定値が揺れて微分項が暴れます。Timer2の分周を1にするとD3のPWMは約31kHzになり、フォトレジスタから見て直流と同じになります。
TCCR2B = (TCCR2B & 0b11111000) | 0x01;この3ビットが分周の選択です(ATmega328Pデータシート の Timer/Counter2 の節)。Timer2が担当するD3とD11のPWMだけに掛かるので、
millis()やdelay()(Timer0)には影響しません。うまく動かないとき
- 明るさの値が100%から動かない
- A0がGNDへ落ちる道がありません。10kΩがGND側につながっているかを確認してください。真っ暗な部屋で正しく配線されていれば1%前後になります。
- 明るくすると値が下がる
- 分圧の上下が逆です。5V側がフォトレジスタ、GND側が10kΩにしてください。このままだと制御が発散します。
- LEDが光らない
- 向きが逆です。足の長いほう(アノード)を220Ω側へ。または220Ωと10kΩの取り違えです。5本帯なら220Ωは赤が2本、10kΩは赤が1本で見分けられます。
- 波形が毎回ちがう
- 外の光が入っています。LEDとフォトレジスタを覆ってください。人が横を通った影でも値が動きます。
- 出力が上限に張り付いたまま戻らない
- 積分ワインドアップです。目標値がそもそも届く範囲かを、静ゲインから計算して確認してください。
練習問題
Q1(書く)
積分は integral += error * dt; の1行です。ここへ積分ワインドアップの対策を if 文1つで足してください。
解答・解説を見る
足した結果が出せる範囲に収まるときだけ積分します。if (!((u_cand > out_max && error > 0) || (u_cand < out_min && error < 0))) { integral += error * dt; }
ただしifで見るのは、飽和していてしかもさらに押し込む向きのときだけです。実測では、これだけで復帰時間が2.15秒から0.73秒になり、飽和していた時間が45%から4%になりました。
Q2(書く)
D項を偏差の微分から測定値の微分へ書き換えてください。符号はどうなりますか。
解答・解説を見る
d_raw = -kd * (meas - prev_meas) / dt;
偏差は「目標値−測定値」なので、測定値の変化から見ると符号が反転します。目標値が動かない場面では両者は同じ動きをし、目標値を変えた瞬間だけ違います。
Q3(なぜ)
なぜ微分を偏差ではなく測定値から取るのですか。
解答・解説を見る
目標値を変えた瞬間、偏差が段差になって微分が跳ね上がるからです。測定値は何も変わっていないのに出力だけが振り切れます。実測では、目標を15%から32%へ変えた瞬間に微分項が−0.3から+12.6へ跳ねました。測定値から取ると−0.7で、跳ねません。ローパスが無ければ跳ねる量は Kd × 段差 ÷ dt = 0.070 × 17 ÷ 0.010 = 119 カウントです。
Q4(予測)
このコードで、届かない目標値を10秒間出し続けたあと目標を下げると、操作量と積分項はどうなりますか。答えを見る前に書き出してください。
解答・解説を見る
対策なしなら、10秒ぶんの偏差が積分に溜まり続けます。実測では3秒で1,367まで育ちました(操作量の上限は255)。10秒ならさらに大きくなります。目標を下げても、この溜まった分が抜けるまで操作量は上限に張り付いたままです。溜めた時間が長いほど、戻ってこない時間も長くなります。
Q5(予測)
Kdを0.070から0.70へ上げると、波形はどう変わりますか。
解答・解説を見る
悪くなります。実測では微分項の最大が−2,017.7(操作量の上限の7.9倍)まで跳ね、操作量が飽和していた時間は37%から49%へ増えました。明るさの振れ幅は30.06%から31.21%までしか増えませんが、これはすでに上下を振り切っていて、振れ幅では差が出なくなっているためです。Dを上げれば安定するという予想は外れます。
1週間後に、この練習問題だけを解答を見ずにもう一度解いてください。書けたところが身についたところです。書けなかった問題は、その節だけ読み返せば十分です。
まとめ
- ゲインは勘で決めない。先に静ゲインとむだ時間・時定数を測り、計算で出発点を出す
- P項だけでは偏差が残る。Kp=1で17.67%、Kp=8で6.75%。上げても消えない
- 安定限界は測るしかない。Kp=5.5まで振れ幅3.03%で静かなのに、6.0で21.74%へ跳んだ。偏差はその間ほとんど動かないので、見るのは振れ幅
- I項が偏差を埋める。実測で偏差−0.05%。そのかわりワインドアップが起きる
- ワインドアップの対策は
if文1つ。復帰2.15秒→0.73秒、飽和45%→4% - 生の微分は使わない。Kdを足すと振れ幅2.65%→30.06%と、静定していたループが振動に変わった。ただしKdを変えずにローパスを入れるだけで30.58%→2.30%まで収まる(こちらは目標32%での比較)
- 微分キックの対策は測定値の微分。目標を変えた瞬間の微分項が+12.6→−0.7。ローパスとは直す相手が違うので、両方入れる
- 1回の測定で決めない。4回ずつ測ると4指標とも差が出た。ただし同じ行き過ぎでも、平滑して測るとばらつきが11倍に悪化した
この記事で書いたPIDのコードは、そのまま他の制御対象にも使えます。変えるのはゲインと制御周期だけ。ただし制御対象が変われば静ゲインもむだ時間も変わるので、ゲインは測り直してください。