PID制御のPython実装:シミュレーションとチューニング

PID制御のPython実装をPIDControllerクラスとFirstOrderSystemで解説。アンチワインドアップ設計、np.arangeとplt.subplotsを使ったP/PI/PID応答比較、Ziegler-Nichols法チューニング、外乱抑制シミュレーションまで確認します。

はじめに

PID制御は、産業用途で最も広く使われるフィードバック制御手法です。温度制御、モータ制御、プロセス制御など、あらゆる分野で用いられています。

PID制御の基礎理論については PID制御の基礎理論と各要素の役割 を参照してください。本記事では、PID制御器をPythonで実装し、1次遅れ系に対するシミュレーションを通じて、P制御・PI制御・PID制御の応答特性を比較します。さらに、古典的なチューニング手法であるZiegler-Nichols法についても紹介します。

離散PID制御器の実装

連続時間のPID制御則は以下のように表されます。

\[ u(t) = K_P e(t) + K_I \int_0^t e(\tau)d\tau + K_D \frac{de(t)}{dt} \]

ここで、\(e(t) = r(t) - y(t)\) は目標値と出力の偏差です。計算機上で実装するためには、積分と微分を離散近似する必要があります。積分は累積和、微分は後退差分で近似すると、離散PID制御則は次のようになります。

\[ u[k] = K_P e[k] + K_I \Delta t \sum_{i=0}^{k} e[i] + K_D \frac{e[k] - e[k-1]}{\Delta t} \tag{1} \]

ここで、\(\Delta t\) はサンプリング周期です。

以下にPythonでの実装を示します。出力制限とアンチワインドアップ機構を含んでいます。

class PIDController:
    def __init__(self, kp, ki, kd, dt, output_limits=None):
        self.kp = kp
        self.ki = ki
        self.kd = kd
        self.dt = dt
        self.output_limits = output_limits
        self.integral = 0.0
        self.prev_error = 0.0

    def update(self, setpoint, measured):
        error = setpoint - measured

        # 比例項
        p_term = self.kp * error

        # 積分項(累積和による近似)
        self.integral += error * self.dt
        i_term = self.ki * self.integral

        # 微分項(後退差分による近似)
        derivative = (error - self.prev_error) / self.dt
        d_term = self.kd * derivative
        self.prev_error = error

        output = p_term + i_term + d_term

        # 出力制限(アンチワインドアップ)
        if self.output_limits is not None:
            lo, hi = self.output_limits
            if output > hi:
                output = hi
                self.integral -= error * self.dt
            elif output < lo:
                output = lo
                self.integral -= error * self.dt

        return output

    def reset(self):
        self.integral = 0.0
        self.prev_error = 0.0

アンチワインドアップは、操作量が上下限に達した場合に積分項の蓄積を停止する仕組みです。これがないと、制約下で積分値が際限なく増大し、制約解除後に大きなオーバーシュートを引き起こします。

制御対象のモデル

シミュレーションの制御対象として、1次遅れ系を用います。伝達関数は以下の通りです。

\[ G(s) = \frac{K}{1 + Ts} \tag{2} \]

ここで、\(K\) はプロセスゲイン、\(T\) は時定数です。この微分方程式を離散化すると、次の更新式が得られます。

\[ y[k+1] = y[k] + \frac{\Delta t}{T}(K \cdot u[k] - y[k]) \tag{3} \]

Pythonでの実装は以下の通りです。

class FirstOrderSystem:
    def __init__(self, gain, time_constant, dt):
        self.gain = gain
        self.time_constant = time_constant
        self.dt = dt
        self.y = 0.0

    def update(self, u):
        self.y += self.dt / self.time_constant * (self.gain * u - self.y)
        return self.y

    def reset(self):
        self.y = 0.0

P制御・PI制御・PID制御の応答比較

3種類の制御器でステップ応答を比較します。制御対象は \(K=1.0\) 、\(T=1.0\) の1次遅れ系です。

import numpy as np
import matplotlib.pyplot as plt

dt = 0.01
t_end = 10.0
t = np.arange(0, t_end, dt)
setpoint = np.ones_like(t)  # ステップ入力

plant_params = {'gain': 1.0, 'time_constant': 1.0, 'dt': dt}

configs = [
    ('P control (Kp=2.0)', {'kp': 2.0, 'ki': 0.0, 'kd': 0.0}),
    ('PI control (Kp=2.0, Ki=1.0)', {'kp': 2.0, 'ki': 1.0, 'kd': 0.0}),
    ('PID control (Kp=2.0, Ki=1.0, Kd=0.5)', {'kp': 2.0, 'ki': 1.0, 'kd': 0.5}),
]

fig, axes = plt.subplots(2, 1, figsize=(10, 8), sharex=True)

for label, params in configs:
    pid = PIDController(**params, dt=dt)
    plant = FirstOrderSystem(**plant_params)
    y_hist = []
    u_hist = []

    for sp in setpoint:
        u = pid.update(sp, plant.y)
        y = plant.update(u)
        y_hist.append(y)
        u_hist.append(u)

    axes[0].plot(t, y_hist, label=label)
    axes[1].plot(t, u_hist, label=label)

axes[0].axhline(y=1.0, color='k', linestyle='--', alpha=0.5, label='Setpoint')
axes[0].set_ylabel('Output y(t)')
axes[0].set_title('Step Response Comparison')
axes[0].legend()
axes[0].grid(True, alpha=0.3)

axes[1].set_ylabel('Control input u(t)')
axes[1].set_xlabel('Time [s]')
axes[1].legend()
axes[1].grid(True, alpha=0.3)

plt.tight_layout()
plt.show()

シミュレーション結果から、以下の特性が確認できます。

  • P制御: 応答は速いものの、定常偏差が残ります。\(K_P = 2.0\) のとき、理論的な定常偏差は \(\frac{1}{1 + K \cdot K_P} = \frac{1}{3} \approx 0.33\) です。
  • PI制御: 積分項の効果により定常偏差が解消されます。ただし、オーバーシュートが発生しやすくなります。
  • PID制御: 微分項によりオーバーシュートが抑制され、P制御の速応性とPI制御の定常特性を両立できます。

ゲインパラメータの影響

各ゲインパラメータの設定は制御性能に大きく影響します。

比例ゲイン \(K_P\) が大きすぎる場合

\(K_P\) を過大に設定すると、応答が振動的になり、最終的には不安定になります。例えば、P制御で \(K_P = 10.0\) とした場合、出力は目標値の周りで振動します。

積分ゲイン \(K_I\) とワインドアップ

\(K_I\) が大きい場合、または操作量に制限がある場合、積分項が過度に蓄積する「ワインドアップ」が発生します。操作量が飽和している間も積分値が増え続けるため、飽和から復帰した後に大きなオーバーシュートを引き起こします。前述のアンチワインドアップ機構はこの問題を軽減します。ワインドアップが起こるメカニズムと代表的な対策の数式的な導出は、後述の「 アンチワインドアップの数理 」で詳しく扱います。

微分ゲイン \(K_D\) とノイズ感度

微分項は偏差の変化率を使うため、測定ノイズに敏感です。ノイズが含まれる信号に対して大きな \(K_D\) を設定すると、操作量が激しく変動します。実用上は、微分項の入力にローパスフィルタを適用する「不完全微分」がよく用いられます。フィルタの設計と、目標値急変時の「微分キック」への対処は、後述の「 微分キック(Derivative Kick)と微分先行型PID 」「 むだ時間とセンサノイズへの対処 」で詳しく扱います。

パラメータ調整の指針

パラメータ増加させると注意点
\(K_P\)応答が速くなる、定常偏差が減少大きすぎると振動・不安定
\(K_I\)定常偏差が解消されるオーバーシュート増加、ワインドアップ
\(K_D\)オーバーシュート抑制、応答改善ノイズに敏感

アンチワインドアップの数理

積分ワインドアップのメカニズム

式(1)の離散PID則は、操作量に上下限がない場合を暗黙に仮定していました。実際のアクチュエータには必ず可動範囲の限界があるため、非飽和の制御指令

\[ u_{unsat}(t) = K_P e(t) + K_I \int_0^t e(\tau) d\tau + K_D \frac{de(t)}{dt} \tag{4} \]

は、実際には次のように飽和させられます。

\[ u(t) = \begin{cases} u_{max} & (u_{unsat}(t) > u_{max}) \\ u_{unsat}(t) & (u_{min} \le u_{unsat}(t) \le u_{max}) \\ u_{min} & (u_{unsat}(t) < u_{min}) \end{cases} \]

ここで問題になるのが積分項の挙動です。素朴な実装(式(1)そのまま)では、積分状態 \(I(t) = \int_0^t e(\tau)d\tau\) は偏差 \(e(t)\) にのみ依存し、実際に印加された操作量 \(u(t)\) が飽和によって \(u_{unsat}(t)\) と乖離しているかどうかを一切参照しません。したがって、アクチュエータが飽和している間も \(e(t)\) が同符号であり続ける限り、\(I(t)\) は制御が実際には効いていないにもかかわらず際限なく増加し続けます。これが積分ワインドアップです。出力がようやく目標値に近づき \(e(t)\) の符号が反転しても、蓄積された過剰な \(I(t)\) を打ち消すには反対符号の偏差を長時間積分し続ける必要があり、その間 \(u_{unsat}(t)\) は飽和限界を超えたままになるため、結果として大きなオーバーシュートと長い整定時間を招きます。

back-calculation法

back-calculation法は、飽和による操作量の乖離 \(u(t) - u_{unsat}(t)\) を、時定数 \(T_t\) (tracking time constant)に対応するゲイン \(1/T_t\) でフィードバックし、積分項の蓄積そのものを能動的に抑制する手法です。積分項を \(v(t) = K_I \int_0^t e(\tau) d\tau\) (出力と同じ次元を持つ量)とし、その時間微分を次のように修正します。

\[ \frac{dv}{dt} = K_I e(t) + \frac{1}{T_t}\big(u(t) - u_{unsat}(t)\big) \tag{5} \]

非飽和時は \(u(t)=u_{unsat}(t)\) なので第2項はゼロとなり、通常の積分と一致します。飽和が持続している状況(\(u(t)=u_{max}\) が一定、\(e(t)=e_0>0\) が一定と仮定)を考えると、定常状態 \(dv/dt=0\) では

\[ K_I e_0 = \frac{1}{T_t}\big(u_{max} - u_{unsat}^{ss}\big) \]

より

\[ u_{unsat}^{ss} = u_{max} + K_I T_t e_0 \]

が成り立ちます。つまりback-calculation法のもとでは、内部の非飽和信号 \(u_{unsat}(t)\) は \(u_{max}\) からわずかに \(K_I T_t e_0\) だけ超過した有限の値に収束し、無制限には発散しません。\(T_t \to 0\) (フィードバックゲイン \(1/T_t \to \infty\) )の極限を取ると \(u_{unsat}^{ss} \to u_{max}\) となり、これは次に述べるclamping法と一致します。\(T_t\) は経験的に積分時間 \(T_I = K_P/K_I\) 程度〜それより小さい値に選ぶことが多いとされています。

clamping法と条件付き積分

clamping法は、飽和が発生している間、積分項の更新を停止する手法です。本記事冒頭のPIDControllerクラスはこの方式を実装しています(出力制限判定後にself.integral -= error * self.dtで加算分を打ち消す処理)。数式で書くと、

\[ \frac{dI}{dt} = \begin{cases} 0 & (u_{unsat}(t) \text{ が飽和中}) \\ e(t) & (\text{それ以外}) \end{cases} \]

これはback-calculation法において \(T_t \to 0\) とした極限に相当します。ただし、この単純な実装は「飽和中は常に積分を止める」という判定であるため、偏差の符号が反転して飽和状態から抜け出す方向に向かっている場合まで積分を止めてしまう可能性があります。これを避けるための改良が条件付き積分(conditional integration)で、飽和の方向と偏差の符号が一致する場合にのみ積分を停止し、逆符号(デサチュレーションを助ける方向)の場合は積分を継続します。

\[ \frac{dI}{dt} = \begin{cases} 0 & \big(u_{unsat}(t) \text{ が飽和中かつ } \mathrm{sign}(e(t)) = \mathrm{sign}(u_{unsat}(t) - u(t))\big) \\ e(t) & (\text{それ以外}) \end{cases} \]

実行検証:アンチワインドアップの効果

一次遅れ系(\(K=1\) 、\(T=1\) )に対し、\(K_P=1.0\) 、\(K_I=3.0\) 、\(K_D=0.5\) 、操作量制限 \(u \in [-1.2, 1.2]\) (定常状態で必要な操作量は \(u_{ss}=1.0\) なので、飽和は主に立ち上がり過渡でのみ発生する設定)という条件で、目標値 \(r=1.0\) へのステップ応答を、(a) アンチワインドアップなし、(b) clamping法、(c) back-calculation法(\(T_t=0.3\) )の3通りで比較しました。

import numpy as np

class FirstOrderSystem:
    def __init__(self, gain, time_constant, dt):
        self.gain = gain
        self.time_constant = time_constant
        self.dt = dt
        self.y = 0.0
    def update(self, u):
        self.y += self.dt / self.time_constant * (self.gain * u - self.y)
        return self.y

class PIDNoAntiWindup:
    """アンチワインドアップなし:積分は常に加算し続ける"""
    def __init__(self, kp, ki, kd, dt, output_limits):
        self.kp, self.ki, self.kd, self.dt = kp, ki, kd, dt
        self.lo, self.hi = output_limits
        self.integral = 0.0
        self.prev_error = None
    def update(self, setpoint, measured):
        error = setpoint - measured
        if self.prev_error is None:
            self.prev_error = error
        self.integral += error * self.dt
        p = self.kp * error
        i = self.ki * self.integral
        d = self.kd * (error - self.prev_error) / self.dt
        self.prev_error = error
        u_unsat = p + i + d
        return min(max(u_unsat, self.lo), self.hi), u_unsat

class PIDClamping:
    """clamping法:飽和中は積分の加算を打ち消す"""
    def __init__(self, kp, ki, kd, dt, output_limits):
        self.kp, self.ki, self.kd, self.dt = kp, ki, kd, dt
        self.lo, self.hi = output_limits
        self.integral = 0.0
        self.prev_error = None
    def update(self, setpoint, measured):
        error = setpoint - measured
        if self.prev_error is None:
            self.prev_error = error
        self.integral += error * self.dt
        p = self.kp * error
        i = self.ki * self.integral
        d = self.kd * (error - self.prev_error) / self.dt
        self.prev_error = error
        u_unsat = p + i + d
        u = u_unsat
        if u > self.hi:
            u = self.hi
            self.integral -= error * self.dt
        elif u < self.lo:
            u = self.lo
            self.integral -= error * self.dt
        return u, u_unsat

class PIDBackCalc:
    """back-calculation法:飽和誤差を1/Ttでフィードバック"""
    def __init__(self, kp, ki, kd, dt, output_limits, Tt):
        self.kp, self.ki, self.kd, self.dt = kp, ki, kd, dt
        self.lo, self.hi = output_limits
        self.Tt = Tt
        self.integral = 0.0
        self.prev_error = None
    def update(self, setpoint, measured):
        error = setpoint - measured
        if self.prev_error is None:
            self.prev_error = error
        p = self.kp * error
        i = self.ki * self.integral
        d = self.kd * (error - self.prev_error) / self.dt
        self.prev_error = error
        u_unsat = p + i + d
        u = min(max(u_unsat, self.lo), self.hi)
        self.integral += (error + (u - u_unsat) / self.Tt) * self.dt
        return u, u_unsat

def run(controller_cls, kwargs, t_end, dt, setpoint_val):
    t = np.arange(0, t_end, dt)
    plant = FirstOrderSystem(gain=1.0, time_constant=1.0, dt=dt)
    ctrl = controller_cls(dt=dt, **kwargs)
    y_hist, int_hist = [], []
    for _ in t:
        u, u_unsat = ctrl.update(setpoint_val, plant.y)
        y = plant.update(u)
        y_hist.append(y)
        int_hist.append(ctrl.integral)
    return t, np.array(y_hist), np.array(int_hist)

dt, t_end, setpoint_val = 0.01, 15.0, 1.0
kwargs_common = dict(kp=1.0, ki=3.0, kd=0.5, output_limits=(-1.2, 1.2))

results = {
    "No anti-windup": run(PIDNoAntiWindup, kwargs_common, t_end, dt, setpoint_val),
    "Clamping": run(PIDClamping, kwargs_common, t_end, dt, setpoint_val),
    "Back-calculation (Tt=0.3)": run(PIDBackCalc, dict(**kwargs_common, Tt=0.3), t_end, dt, setpoint_val),
}

for name, (t, y, integral) in results.items():
    overshoot = (np.max(y) - setpoint_val) / setpoint_val * 100
    tol = 0.02 * setpoint_val
    settle_idx = next(i for i in range(len(y)) if np.all(np.abs(y[i:] - setpoint_val) < tol))
    print(f"{name}: overshoot={overshoot:.2f}%, settle_t={t[settle_idx]:.2f}s, "
          f"peak_integral={np.max(integral):.4f}")
No anti-windup: overshoot=17.71%, settle_t=7.32s, peak_integral=0.7127
Clamping: overshoot=7.67%, settle_t=3.94s, peak_integral=0.4301
Back-calculation (Tt=0.3): overshoot=7.86%, settle_t=3.95s, peak_integral=0.4318

アンチワインドアップの効果比較

実測結果は以下の通りです。

手法オーバーシュート整定時間(2%以内)積分状態のピーク値
アンチワインドアップなし17.71%7.32 s0.7127
clamping法7.67%3.94 s0.4301
back-calculation法(\(T_t=0.3\) )7.86%3.95 s0.4318

アンチワインドアップなしでは積分状態が0.71付近まで積み上がり、オーバーシュートが約17.7%、整定時間も7.32秒とほぼ倍近くに悪化しています。clamping法とback-calculation法はほぼ同等の性能改善(オーバーシュートの半減、整定時間の大幅短縮)を示しており、\(T_t=0.3\) 程度のback-calculation法はclamping法とほぼ等価に振る舞うことも確認できます(図の積分状態の軌跡がほぼ重なっています)。

離散化誤差の解析:後退差分・前進差分・双一次変換

式(1)の離散PID則は、積分を「現在サンプルの偏差を都度加算する」後退差分(backward difference)で近似していました。この近似が周波数特性に与える誤差を、他の代表的な離散化手法(前進差分・双一次変換)と比較して分析します。

3つの離散化手法

連続時間の積分器 \(1/s\) を離散化する代表的な3手法を、積分状態 \(I[k] \approx \int_0^{k\Delta t} e(\tau)d\tau\) の更新式とその \(z\) 変換で整理すると、次のようになります。

後退差分(backward Euler)は現在サンプルの偏差を直ちに反映します。

\[ I[k] = I[k-1] + \Delta t \, e[k] \quad\Longleftrightarrow\quad \frac{I(z)}{E(z)} = \frac{\Delta t \, z}{z-1} \tag{6} \]

前進差分(forward Euler)は1サンプル前までの偏差しか反映しません(現在の積分状態は現在の偏差を含まない)。

\[ I[k] = I[k-1] + \Delta t \, e[k-1] \quad\Longleftrightarrow\quad \frac{I(z)}{E(z)} = \frac{\Delta t}{z-1} \tag{7} \]

双一次変換(Tustin変換、台形則)は現在と1サンプル前の偏差の平均を使います。

\[ I[k] = I[k-1] + \frac{\Delta t}{2}\big(e[k] + e[k-1]\big) \quad\Longleftrightarrow\quad \frac{I(z)}{E(z)} = \frac{\Delta t}{2}\cdot\frac{z+1}{z-1} \tag{8} \]

周波数ひずみ(frequency warping)

双一次変換は \(s = \dfrac{2}{\Delta t}\cdot\dfrac{z-1}{z+1}\) という変数変換に対応します。単位円上の点 \(z = e^{j\omega\Delta t}\) を代入すると、

\[ \frac{z-1}{z+1} = \frac{e^{j\omega\Delta t}-1}{e^{j\omega\Delta t}+1} = j\tan\!\left(\frac{\omega \Delta t}{2}\right) \]

したがって、双一次変換で離散化された積分器を実際の角周波数 \(\omega\) で評価すると、連続時間の積分器を次の「ゆがんだ角周波数」で評価したときと等価な応答を示します。

\[ \omega' = \frac{2}{\Delta t}\tan\!\left(\frac{\omega \Delta t}{2}\right) \]

\(\omega \Delta t\) が小さい(サンプリングが十分細かい)うちは \(\omega' \approx \omega\) ですが、ナイキスト周波数(\(\omega \Delta t \to \pi\) )に近づくと \(\tan\) が発散し、周波数特性が大きく歪みます。これが双一次変換に特有の「周波数ひずみ」です。一方、後退差分は \(s = \dfrac{z-1}{z\Delta t}\) という異なる変数変換に対応し、虚軸 \(j\omega\) は単位円ではなく円 \(|z - 1/2| = 1/2\) に写像されるため、積分器単体で見た入出力関係のひずみ方は双一次変換とは異なります。

実行検証:安定性と追従性の比較

一次遅れ系(\(K=1\) 、\(T=1\) )を厳密にZOH(zero-order hold)離散化した閉ループ系 \(y[k+1] = a\,y[k] + b\,u[k]\) (\(a = e^{-\Delta t/T}\) 、\(b=K(1-a)\) )に対し、\(K_P=2.0\) 、\(K_I=1.0\) のPI制御器を上記3手法で離散化して閉ループの特性方程式の根を求め、両方の根の絶対値が1未満となる(=安定な)最大のサンプリング周期 \(\Delta t\) を二分探索で求めました。

import numpy as np

K_plant, T_plant = 1.0, 1.0
Kp, Ki = 2.0, 1.0

def poles(dt, method):
    """厳密にZOH離散化された一次遅れ系とPI制御器(methodで離散化)から成る
    閉ループの特性方程式の根"""
    a = np.exp(-dt / T_plant)
    b = K_plant * (1 - a)
    if method == "backward":
        c1 = -(1 + a) + b * Kp + b * Ki * dt
        c0 = a - b * Kp
    elif method == "forward":
        c1 = -(1 + a) + b * Kp
        c0 = a - b * Kp + b * Ki * dt
    elif method == "tustin":
        c1 = -(1 + a) + b * Kp + b * Ki * dt / 2
        c0 = a - b * Kp + b * Ki * dt / 2
    return np.roots([1.0, c1, c0])

def is_stable(dt, method):
    return np.all(np.abs(poles(dt, method)) < 1.0)

print("安定限界(両方の根が単位円内に収まる最大のdt):")
for method in ["backward", "forward", "tustin"]:
    lo, hi = 0.01, 5.0
    if is_stable(hi, method):
        print(f"  {method}: dt={hi}でも安定")
        continue
    for _ in range(60):
        mid = (lo + hi) / 2
        lo, hi = (mid, hi) if is_stable(mid, method) else (lo, mid)
    print(f"  {method}: 安定限界 dt ~ {lo:.3f} s")

# 後退差分の安定限界付近(dt=0.8s)での時間応答
from scipy.integrate import solve_ivp

def simulate(dt, method, t_end=30.0, setpoint=1.0):
    y, integral, prev_error = 0.0, 0.0, 0.0
    t_hist, y_hist = [0.0], [0.0]
    for k in range(int(t_end / dt)):
        error = setpoint - y
        if method == "backward":
            integral += error * dt
            i_term = Ki * integral
        elif method == "forward":
            i_term = Ki * integral
            integral += error * dt
        elif method == "tustin":
            integral += (error + prev_error) / 2 * dt
            i_term = Ki * integral
        u = Kp * error + i_term
        prev_error = error
        sol = solve_ivp(lambda tt, yy: (K_plant * u - yy) / T_plant, [0, dt], [y])
        y = sol.y[0, -1]
        t_hist.append(t_hist[-1] + dt)
        y_hist.append(y)
    return np.array(t_hist), np.array(y_hist)

print("\ndt=0.8sでの時間応答:")
for method in ["backward", "tustin", "forward"]:
    t_hist, y_hist = simulate(0.8, method)
    overshoot = (np.max(y_hist) - 1.0) * 100
    tol = 0.02
    settle_idx = next((i for i in range(len(y_hist)) if np.all(np.abs(y_hist[i:] - 1.0) < tol)), None)
    settle_t = t_hist[settle_idx] if settle_idx is not None else None
    print(f"  {method:9s}: overshoot={overshoot:.2f}%, settle_t={settle_t}")
安定限界(両方の根が単位円内に収まる最大のdt):
  backward: 安定限界 dt ~ 0.872 s
  forward: 安定限界 dt ~ 3.000 s
  tustin: 安定限界 dt ~ 1.099 s

dt=0.8sでの時間応答:
  backward : overshoot=54.18%, settle_t=19.200000000000006
  tustin   : overshoot=32.16%, settle_t=7.199999999999999
  forward  : overshoot=10.13%, settle_t=4.8
離散化手法安定限界 \(\Delta t\)
後退差分約0.872 s
双一次変換約1.099 s
前進差分約3.0 s以上

さらに、\(\Delta t = 0.8\) s(後退差分の安定限界のすぐ手前)での時間応答をシミュレーションすると、以下の結果が得られました。

離散化手法オーバーシュート整定時間(2%以内)
後退差分54.18%19.2 s
双一次変換32.16%7.2 s
前進差分10.13%4.8 s

\(\Delta t \le 0.5\) s程度までは3手法の応答はほとんど一致し(\(\Delta t = 0.5\) sではいずれもオーバーシュートなし)、プラントの時定数 \(T=1\) に対して十分細かいサンプリングが取れている限り離散化手法の選択は問題になりません。しかし \(\Delta t\) が \(T\) に対して粗くなるにつれ差が顕在化し、この例では前進差分が最も安定余裕・追従性ともに優れるという結果になりました。これは、前進差分が1サンプル分の遅れを積分経路に持ち込む結果、粗い \(\Delta t\) における実効ループゲインが後退差分・双一次変換より小さく抑えられるためです。ただし、これは「前進差分が一般に優れている」ことを意味するものではありません。この遅れは通常望ましくない追加むだ時間として働くため、実務では後退差分あるいは双一次変換を使い、\(\Delta t\) をプラントの支配的時定数の1/10程度以下に選ぶ(\(\Delta t \lesssim T/10\) )ことで、離散化誤差そのものを無視できる領域で運用するのが標準的な指針です。

微分キック(Derivative Kick)と微分先行型PID

問題:目標値変化時の出力スパイク

式(1)の微分項は、偏差 \(e[k] = r[k] - y[k]\) の差分 \(\dfrac{e[k]-e[k-1]}{\Delta t}\) で計算されます。これを目標値と観測値それぞれの差分に分解すると、

\[ \frac{e[k]-e[k-1]}{\Delta t} = \frac{r[k]-r[k-1]}{\Delta t} - \frac{y[k]-y[k-1]}{\Delta t} \]

目標値 \(r\) がステップ状に変化する瞬間(\(r[k] \ne r[k-1]\) )には、右辺第1項が有限の階差 \(\Delta r\) を \(\Delta t\) で割った値になり、\(\Delta t\) が小さいほど非常に大きな値になります。これが操作量に瞬間的な大きなスパイクを生む**微分キック(derivative kick)**です。

解決策:微分先行型(derivative on measurement)

微分項を偏差 \(e\) ではなく観測値 \(y\) にのみ作用させれば、この問題を回避できます。

\[ d[k] = -K_D \frac{y[k]-y[k-1]}{\Delta t} \]

観測値 \(y(t)\) は制御対象(プラント)の状態変数であり、有限帯域の物理系である限り目標値のようにステップ状に飛ぶことはありません。したがって、この定義では目標値の変化がどれだけ急でも微分項にスパイクは発生しません。また、目標値が一定に保たれている区間(\(r[k]=r[k-1]\) )では \(e[k]-e[k-1] = -(y[k]-y[k-1])\) が成り立つため、微分先行型は通常の微分項と完全に同じ値を返します。つまり、外乱抑制やノイズへの応答特性を一切犠牲にすることなく、目標値変化時のキックだけを取り除くことができます。

実行検証

一次遅れ系(\(K=1\) 、\(T=1\) )に対し \(K_P=2.0\) 、\(K_I=1.0\) 、\(K_D=0.5\) のPID制御器を用い、目標値を \(t=5\) sで \(1.0 \to 2.0\) にステップ変化させたときの操作量を、通常の微分(derivative-on-error)と微分先行型(derivative-on-measurement)で比較しました。

import numpy as np

class FirstOrderSystem:
    def __init__(self, gain, time_constant, dt):
        self.gain = gain
        self.time_constant = time_constant
        self.dt = dt
        self.y = 0.0
    def update(self, u):
        self.y += self.dt / self.time_constant * (self.gain * u - self.y)
        return self.y

class PIDErrorDerivative:
    """通常形式:微分項が偏差 e = r - y に作用する"""
    def __init__(self, kp, ki, kd, dt):
        self.kp, self.ki, self.kd, self.dt = kp, ki, kd, dt
        self.integral = 0.0
        self.prev_error = None
    def update(self, setpoint, measured):
        error = setpoint - measured
        if self.prev_error is None:
            self.prev_error = error
        self.integral += error * self.dt
        p = self.kp * error
        i = self.ki * self.integral
        d = self.kd * (error - self.prev_error) / self.dt
        self.prev_error = error
        return p + i + d

class PIDMeasurementDerivative:
    """微分先行型:微分項が観測値 -y のみに作用する"""
    def __init__(self, kp, ki, kd, dt):
        self.kp, self.ki, self.kd, self.dt = kp, ki, kd, dt
        self.integral = 0.0
        self.prev_measured = None
    def update(self, setpoint, measured):
        error = setpoint - measured
        if self.prev_measured is None:
            self.prev_measured = measured
        self.integral += error * self.dt
        p = self.kp * error
        i = self.ki * self.integral
        d = -self.kd * (measured - self.prev_measured) / self.dt
        self.prev_measured = measured
        return p + i + d

dt = 0.01
t = np.arange(0, 12.0, dt)
setpoint = np.where(t < 5.0, 1.0, 2.0)  # t=5sで目標値がステップ変化

for name, cls in [("Derivative-on-error", PIDErrorDerivative),
                   ("Derivative-on-measurement", PIDMeasurementDerivative)]:
    pid = cls(kp=2.0, ki=1.0, kd=0.5, dt=dt)
    plant = FirstOrderSystem(gain=1.0, time_constant=1.0, dt=dt)
    u_hist = []
    for sp in setpoint:
        u = pid.update(sp, plant.y)
        plant.update(u)
        u_hist.append(u)
    u_hist = np.array(u_hist)
    idx_step = np.argmax(t >= 5.0)
    print(f"{name}: u just before step={u_hist[idx_step-1]:.4f}, "
          f"u just after step={u_hist[idx_step]:.4f}, "
          f"jump={u_hist[idx_step]-u_hist[idx_step-1]:.4f}")
Derivative-on-error: u just before step=0.9852, u just after step=52.9953, jump=52.0101
Derivative-on-measurement: u just before step=0.9852, u just after step=2.9953, jump=2.0101
方式ステップ直前の \(u\)ステップ直後の \(u\)変化量
derivative-on-error(通常)0.985252.9953+52.010
derivative-on-measurement0.98522.9953+2.010

通常の微分項では目標値ステップの瞬間に操作量が約53まで跳ね上がる激しいキックが発生しますが、微分先行型では比例項の変化分(\(K_P \Delta r = 2.0\) )のみの穏やかな変化(約2.01の増分で、ほぼP項のジャンプ幅と一致)に収まっています。実際のアクチュエータでこのようなスパイクが発生すると機械的な衝撃や飽和を招くため、微分先行型は実務上ほぼ標準的な実装になっています。

むだ時間とセンサノイズへの対処

むだ時間(デッドタイム)とPID制御の限界

輸送遅れや通信遅延、化学プロセスの反応遅れなどにより、制御対象がむだ時間 \(L\) を伴う場合、伝達関数は

\[ G(s) = \frac{K}{1+Ts} e^{-Ls} \]

の形になります。むだ時間の位相遅れは \(-\omega L\) [rad] であり、周波数が高くなるほど際限なく増大します。この位相遅れは制御器の構造(P・PI・PIDのいずれであっても)に関係なく発生するため、\(L\) が大きいほど得られる位相余裕が減り、安定を保てるゲインの上限が下がって応答を高速化できなくなります(Ziegler-Nichols法のゲイン設定式がいずれも \(L\) に反比例していたのは、この制約を反映したものです)。

\(L/T\) の比が大きい系では、この制約が実用上無視できなくなります。この限界に対処する古典的な方法が、Smith (1957) が提案した**スミス予測器(Smith predictor)**です。基本的な考え方は、プラントの数式モデル \(\hat{G}(s)\) (むだ時間を除いた部分)を制御器内部に持たせ、モデルによるむだ時間なしの予測出力をフィードバック信号として使い、実測値とモデル予測(むだ時間込み)との差分でモデル誤差・外乱を補正するというものです。モデルが正確であれば、制御器は実質的にむだ時間のない系に対してチューニングでき、\(L\) による安定限界の制約を緩和できます。ただし性能はモデルの精度に強く依存するため、モデル化誤差が大きい系では効果が限定的です。

センサノイズと微分項の増幅

微分項は周波数領域で \(K_D \cdot j\omega\) という利得を持ち、角周波数 \(\omega\) に比例して増大します。測定ノイズは一般に広い周波数帯域に成分を持つため、素朴な微分(式(1)の後退差分)はノイズを大きく増幅してしまいます。この対策として実務で広く使われるのが、微分項に一次のローパスフィルタを付加したフィルタ付き微分です。

\[ D(s) = \frac{K_D s}{1 + s/N} = \frac{K_D N s}{s + N} \]

\(N\) は微分フィルタ係数と呼ばれ、フィルタの折れ点角周波数(時定数 \(\tau_f = 1/N\) )を決めます。周波数応答は

\[ |D(j\omega)| = \frac{K_D N \omega}{\sqrt{\omega^2+N^2}} \]

となり、\(\omega \ll N\) では \(|D(j\omega)| \approx K_D \omega\) と理想微分に一致する一方、\(\omega \to \infty\) では \(K_D N\) に飽和し、高周波ノイズを無限に増幅することがなくなります。離散実装では、生の微分値 \(d_{raw}[k] = -K_D(y[k]-y[k-1])/\Delta t\) を一次遅れフィルタ(後退差分で離散化)に通し、

\[ d_{filt}[k] = d_{filt}[k-1] + \frac{\Delta t}{\tau_f}\big(d_{raw}[k] - d_{filt}[k-1]\big) \]

とするのが標準的な実装です(Åström & Hägglund, 1995)。

実行検証:センサノイズ下でのフィルタ付き微分の効果

一次遅れ系(\(K=1\) 、\(T=1\) )に対し、標準偏差0.01の正規分布ノイズを観測値に加えた状態で \(K_P=2.0\) 、\(K_I=1.0\) 、\(K_D=0.5\) のPID制御を行い、フィルタなし・\(N=10\) ・\(N=2\) (より強いフィルタ)の3通りで、定常状態(\(t>5\) s)における操作量の変動と追従誤差を比較しました。

import numpy as np

class FirstOrderSystem:
    def __init__(self, gain, time_constant, dt):
        self.gain = gain
        self.time_constant = time_constant
        self.dt = dt
        self.y = 0.0
    def update(self, u):
        self.y += self.dt / self.time_constant * (self.gain * u - self.y)
        return self.y

class PIDFilteredDerivative:
    """微分先行型に、生の微分値へ時定数1/Nの一次ローパスフィルタを
    (Noneならフィルタなし)追加した実装"""
    def __init__(self, kp, ki, kd, dt, N=None):
        self.kp, self.ki, self.kd, self.dt = kp, ki, kd, dt
        self.N = N
        self.integral = 0.0
        self.prev_measured = None
        self.d_filt = 0.0
    def update(self, setpoint, measured):
        error = setpoint - measured
        if self.prev_measured is None:
            self.prev_measured = measured
        self.integral += error * self.dt
        p = self.kp * error
        i = self.ki * self.integral
        d_raw = -(measured - self.prev_measured) / self.dt
        self.prev_measured = measured
        if self.N is None:
            d_used = d_raw
        else:
            tau_f = 1.0 / self.N
            alpha = min(self.dt / tau_f, 1.0)
            self.d_filt += alpha * (d_raw - self.d_filt)
            d_used = self.d_filt
        return p + i + self.kd * d_used

dt = 0.01
t = np.arange(0, 15.0, dt)
setpoint = np.ones_like(t)
noise_std = 0.01

for label, N in [("No filter", None), ("N=10", 10.0), ("N=2 (stronger filter)", 2.0)]:
    np.random.seed(42)
    pid = PIDFilteredDerivative(kp=2.0, ki=1.0, kd=0.5, dt=dt, N=N)
    plant = FirstOrderSystem(gain=1.0, time_constant=1.0, dt=dt)
    y_hist, u_hist = [], []
    for sp in setpoint:
        noisy_measurement = plant.y + np.random.normal(0, noise_std)
        u = pid.update(sp, noisy_measurement)
        y = plant.update(u)
        y_hist.append(y)
        u_hist.append(u)
    y_hist, u_hist = np.array(y_hist), np.array(u_hist)
    mask = t > 5.0  # 定常状態区間
    u_std = np.std(u_hist[mask])
    u_ptp = np.ptp(u_hist[mask])
    tracking_rmse = np.sqrt(np.mean((y_hist[mask] - 1.0) ** 2))
    print(f"{label}: std(u)={u_std:.4f}, ptp(u)={u_ptp:.4f}, tracking_RMSE={tracking_rmse:.5f}")
No filter: std(u)=1.0377, ptp(u)=7.2849, tracking_RMSE=0.01069
N=10: std(u)=0.0717, ptp(u)=0.4361, tracking_RMSE=0.00876
N=2 (stronger filter): std(u)=0.0301, ptp(u)=0.1786, tracking_RMSE=0.00792
フィルタ設定操作量の標準偏差操作量のPeak-to-Peak追従RMSE
フィルタなし1.03777.28490.01069
\(N=10\)0.07170.43610.00876
\(N=2\) (強フィルタ)0.03010.17860.00792

フィルタなしでは操作量が激しく振動し(標準偏差1.04、Peak-to-Peak 7.28)、\(N=10\) のフィルタでこれを約14.5分の1(標準偏差0.072)まで低減できました。\(N=2\) までフィルタを強めるとさらに滑らかになり、追従RMSEもわずかに改善しています(フィルタがノイズそのものを抑制する副次効果です)。なお、ノイズのない条件で過渡応答(整定時間)を確認したところ、フィルタなし5.60 s、\(N=10\) で5.55 s、\(N=2\) で5.41 sとほとんど差はなく、この設定では強めのフィルタを使っても追従性能への悪影響は無視できる範囲でした。ただしフィルタの時定数 \(\tau_f=1/N\) をプラントの応答速度に対して大きくしすぎると位相遅れが無視できなくなり安定余裕を損なうため、\(N\) は経験的にプラントの支配的な時定数の逆数の数倍〜数十倍程度から探索するのが実務的な出発点です。

Ziegler-Nicholsチューニング法

Ziegler-Nichols法は、制御対象のステップ応答特性から経験的にPIDゲインを決定する古典的な手法です。ここでは、ステップ応答法(開ループ法)を紹介します。

ステップ応答法

制御対象にステップ入力を加え、応答曲線から以下の3つのパラメータを読み取ります。

  • \(K\) : プロセスゲイン(定常値/入力値)
  • \(L\) : むだ時間(応答が始まるまでの遅れ)
  • \(T\) : 時定数(応答の変曲点における接線が定常値に達するまでの時間)

これらのパラメータから、以下の表に基づいてPIDゲインを計算します。

制御器\(K_P\)\(T_I\)\(T_D\)
P\(\frac{T}{KL}\)--
PI\(\frac{0.9T}{KL}\)\(\frac{L}{0.3}\)-
PID\(\frac{1.2T}{KL}\)\(2L\)\(0.5L\)

ここで、\(T_I\) は積分時間、\(T_D\) は微分時間であり、\(K_I = K_P / T_I\) 、\(K_D = K_P \cdot T_D\) の関係があります。

Ziegler-Nichols法は振動的な応答を出発点として設計されるため、そのままではオーバーシュートが大きくなる傾向があります。実用上は、この値を初期値として手動で微調整を行うことが一般的です。

外乱応答のシミュレーション

実際の制御系では、外乱(外部からの想定外の入力)が制御性能に影響を与えます。ここでは、シミュレーションの途中でステップ状の外乱を加え、P制御とPID制御の外乱抑制性能を比較します。

import numpy as np
import matplotlib.pyplot as plt

dt = 0.01
t_end = 20.0
t = np.arange(0, t_end, dt)
setpoint = np.ones_like(t)

# 外乱:t=10sで大きさ0.5のステップ外乱
disturbance = np.zeros_like(t)
disturbance[t >= 10.0] = 0.5

plant_params = {'gain': 1.0, 'time_constant': 1.0, 'dt': dt}

configs = [
    ('P control (Kp=2.0)', {'kp': 2.0, 'ki': 0.0, 'kd': 0.0}),
    ('PID control (Kp=2.0, Ki=1.0, Kd=0.5)', {'kp': 2.0, 'ki': 1.0, 'kd': 0.5}),
]

fig, axes = plt.subplots(2, 1, figsize=(10, 8), sharex=True)

for label, params in configs:
    pid = PIDController(**params, dt=dt)
    plant = FirstOrderSystem(**plant_params)
    y_hist = []
    u_hist = []

    for i, sp in enumerate(setpoint):
        u = pid.update(sp, plant.y)
        # 外乱をプラントの入力に加算
        y = plant.update(u + disturbance[i])
        y_hist.append(y)
        u_hist.append(u)

    axes[0].plot(t, y_hist, label=label)
    axes[1].plot(t, u_hist, label=label)

axes[0].axhline(y=1.0, color='k', linestyle='--', alpha=0.5, label='Setpoint')
axes[0].axvline(x=10.0, color='r', linestyle=':', alpha=0.5, label='Disturbance onset')
axes[0].set_ylabel('Output y(t)')
axes[0].set_title('Disturbance Rejection Comparison')
axes[0].legend()
axes[0].grid(True, alpha=0.3)

axes[1].set_ylabel('Control input u(t)')
axes[1].set_xlabel('Time [s]')
axes[1].legend()
axes[1].grid(True, alpha=0.3)

plt.tight_layout()
plt.show()

シミュレーション結果から、以下のことがわかります。

  • P制御: 外乱に対して一定の抑制効果はありますが、外乱による定常偏差が残ります。
  • PID制御: 積分項の効果により、外乱が加わっても最終的に出力は目標値に復帰します。微分項により、外乱に対する初期応答も改善されます。

PIDチューニングの最新動向:自動チューニングとMPCとの使い分け

Ziegler-Nichols法に代表される古典的チューニングは依然として実務の出発点として広く使われていますが、2023年以降、ゲイン探索を最適化問題として自動化する研究が活発化しています。

強化学習によるゲイン探索: Sönmezら(arXiv:2502.04552, 2025)は、クアッドロータの姿勢制御PIDゲインをDDPG(Deep Deterministic Policy Gradient)でオンライン学習し、飛行中にゲインを逐次微調整する手法を提案し、シミュレーションと実機飛行の両方で手動チューニングを上回る追従精度を報告しています。 ベイズ最適化の基礎とPython実装 で扱ったガウス過程による代理モデルベースの探索も、PIDチューニングへの応用が進んでいます。Fujimotoら(2023, Asian Journal of Control)は、ベイズ最適化によるコントローラチューニングとその高速化を概念実証・実機実験の両面で検証しており、プラントモデルの事前知識を獲得関数の事前分布に組み込むことで、必要な試行回数を大幅に削減できることも報告されています。

MPCとの使い分け: PIDと発展的な制御手法(MPCや機械学習ベースの制御器)の使い分けについても、近年整理が進んでいます。Sarhadi(arXiv:2503.14379, 2025)は、名目条件下(飽和・外乱・ノイズ・モデル誤差のない理想状態)ではPIDとMPCの性能はほぼ同等だが、飽和・外乱・センサノイズ・モデル不確かさが加わる実運用条件では制約付きMPCがPIDを大きく上回る一方、アンチワインドアップを備えたPIDは妥当な性能を維持できたと報告しています。これは、本記事で導出したアンチワインドアップの効果と整合する結果であり、「PIDで十分な場面にMPCの複雑さを持ち込む必要はないが、飽和や制約が性能を大きく左右する場面ではアンチワインドアップの実装品質そのものがPIDとMPCの性能差を左右する」という実務的な示唆を与えています。この分野は変化が速いため、具体的な性能数値や手法の詳細については各論文の一次情報を参照することをお勧めします。

まとめ

本記事では、PID制御器をPythonで実装し、1次遅れ系に対するシミュレーションを通じて動作を確認しました。P制御・PI制御・PID制御の応答比較により、各要素の役割と効果を実験的に確認できました。さらに、アンチワインドアップ(back-calculation法・clamping法・条件付き積分)、離散化誤差(後退差分・前進差分・双一次変換)、微分キックと微分先行型PID、むだ時間・センサノイズへの対策(フィルタ付き微分)という、実装上避けて通れない4つの論点を数式的に導出し、それぞれの効果を数値実験で確認しました。

PID制御は構造がシンプルでありながら、これらの実装上の注意点を踏まえて適切にチューニング・実装することで、高い制御性能を実現できます。一方で、非線形性が強いシステムや多入力多出力系、あるいは制約の扱いが本質的に重要な系では、より高度な制御手法が必要になります。

関連記事

参考文献

  • Astrom, K. J., & Murray, R. M. (2021). Feedback Systems: An Introduction for Scientists and Engineers (2nd ed.). Princeton University Press.
  • Ziegler, J. G., & Nichols, N. B. (1942). “Optimum settings for automatic controllers”. Transactions of the ASME, 64(11), 759-768.
  • Åström, K. J., & Hägglund, T. (1995). PID Controllers: Theory, Design, and Tuning (2nd ed.). Instrument Society of America.
  • Smith, O. J. M. (1957). “Closer control of loops with dead time”. Chemical Engineering Progress, 53(5), 217-219.
  • Fujimoto, Y., Sato, H., & Nagahara, M. (2023). “Controller tuning with Bayesian optimization and its acceleration: Concept and experimental validation”. Asian Journal of Control, 25(6).
  • Sönmez, S., Montecchio, L., Martini, S., Rutherford, M. J., Rizzo, A., Stefanovic, M., & Valavanis, K. P. (2025). “Reinforcement Learning Based Prediction of PID Controller Gains for Quadrotor UAVs”. arXiv:2502.04552.
  • Sarhadi, P. (2025). “On the Standard Performance Criteria for Applied Control Design: PID, MPC or Machine Learning Controller?”. arXiv:2503.14379.