第4章:フィードバック制御とPID制御

プロセス制御の基礎理論から実装まで

📖 読了時間: 25-30分 📊 難易度: 中級 💻 コード例: 8個

学習目標

この章を読むことで、以下を習得できます:


4.1 フィードバック制御の基礎

フィードバック制御とは

フィードバック制御は、制御対象の出力(制御量)を測定し、目標値(設定値)との偏差をゼロにするように制御入力(操作量)を調整する制御方式です。プロセス産業において最も広く使用される制御手法です。

制御系の基本構成

graph LR SP[設定値
Setpoint] --> SUM((+
-)) SUM --> |偏差 e| CTRL[制御器
Controller] CTRL --> |操作量 u| PROCESS[プロセス
Process] PROCESS --> |制御量 y| OUTPUT[出力] OUTPUT --> |フィードバック| SUM style SP fill:#e8f5e9 style CTRL fill:#c8e6c9 style PROCESS fill:#a5d6a7 style OUTPUT fill:#81c784

主要な要素:

伝達関数と動的システム

プロセスの動特性は、伝達関数で表現されます。最も基本的なプロセスモデルは1次遅れ系です:

G(s) = K / (τs + 1)

ここで:


4.2 コード例:1次系とPID制御の実装

コード例1: 1次系のステップ応答シミュレーション

目的: 反応器温度制御を例に、1次遅れ系のステップ応答を可視化し、時定数とゲインの影響を理解する。

import numpy as np
import matplotlib.pyplot as plt
from scipy.integrate import odeint

# 日本語フォント設定
plt.rcParams['font.sans-serif'] = ['Hiragino Sans', 'Arial']
plt.rcParams['axes.unicode_minus'] = False

def first_order_system(y, t, K, tau, u):
    """
    1次遅れ系の微分方程式

    τ * dy/dt + y = K * u

    Parameters:
    -----------
    y : float
        制御量(温度)
    t : float
        時間
    K : float
        プロセスゲイン
    tau : float
        時定数(秒)
    u : float
        操作量(ヒーター出力)

    Returns:
    --------
    dydt : float
        制御量の時間微分
    """
    dydt = (K * u - y) / tau
    return dydt

# シミュレーションパラメータ
K = 2.0        # プロセスゲイン(°C / %)
tau = 60.0     # 時定数(秒)
u_step = 10.0  # ステップ入力(ヒーター出力 10%)

# 時間軸
t = np.linspace(0, 300, 1000)  # 0-300秒

# 初期条件(定常状態: 室温20°C)
y0 = 20.0

# 1次系の応答計算
y = odeint(first_order_system, y0, t, args=(K, tau, u_step))

# 理論値(解析解)
y_analytical = K * u_step * (1 - np.exp(-t / tau)) + y0

# 可視化
fig, axes = plt.subplots(2, 1, figsize=(14, 10))

# ステップ応答
axes[0].plot(t, y, 'b-', linewidth=2, label='シミュレーション結果')
axes[0].plot(t, y_analytical, 'r--', linewidth=2, alpha=0.7, label='解析解')
axes[0].axhline(y=y0 + K * u_step, color='green', linestyle='--',
                label=f'定常値: {y0 + K * u_step:.1f}°C')
axes[0].axvline(x=tau, color='orange', linestyle='--', alpha=0.5,
                label=f'時定数 τ = {tau:.0f}秒')
axes[0].axhline(y=y0 + K * u_step * 0.632, color='purple', linestyle=':', alpha=0.5)
axes[0].set_xlabel('時間(秒)', fontsize=12)
axes[0].set_ylabel('温度(°C)', fontsize=12)
axes[0].set_title('1次系のステップ応答(反応器温度制御)', fontsize=14, fontweight='bold')
axes[0].legend(loc='lower right')
axes[0].grid(alpha=0.3)

# 異なる時定数での比較
tau_values = [30, 60, 120]
colors = ['blue', 'red', 'green']

for tau_val, color in zip(tau_values, colors):
    y_comp = odeint(first_order_system, y0, t, args=(K, tau_val, u_step))
    axes[1].plot(t, y_comp, color=color, linewidth=2, label=f'τ = {tau_val}秒')

axes[1].axhline(y=y0 + K * u_step, color='black', linestyle='--', alpha=0.5)
axes[1].set_xlabel('時間(秒)', fontsize=12)
axes[1].set_ylabel('温度(°C)', fontsize=12)
axes[1].set_title('時定数の影響比較', fontsize=14, fontweight='bold')
axes[1].legend()
axes[1].grid(alpha=0.3)

plt.tight_layout()
plt.show()

# 応答特性の計算
print("=== 1次系ステップ応答特性 ===")
print(f"プロセスゲイン K: {K} °C/%")
print(f"時定数 τ: {tau} 秒")
print(f"ステップ入力: {u_step} %")
print(f"定常状態変化: {K * u_step:.2f} °C")
print(f"時定数での到達率: 63.2%")
print(f"3τ での到達率: {(1 - np.exp(-3)) * 100:.1f}%")
print(f"5τ での到達率: {(1 - np.exp(-5)) * 100:.1f}%")

期待される出力:

=== 1次系ステップ応答特性 ===
プロセスゲイン K: 2.0 °C/%
時定数 τ: 60.0 秒
ステップ入力: 10.0 %
定常状態変化: 20.00 °C
時定数での到達率: 63.2%
3τ での到達率: 95.0%
5τ での到達率: 99.3%

解説: 1次遅れ系は、プロセス制御で最も基本的なモデルです。時定数τは、ステップ入力に対して定常値の63.2%に到達するまでの時間を表します。実際のプロセス(温度制御、流量制御等)の多くは1次系または1次系の組み合わせで近似できます。

コード例2: PID制御器の完全実装

目的: P、I、D各要素を含む完全なPID制御器を実装し、各パラメータの影響を理解する。

import numpy as np
import matplotlib.pyplot as plt

class PIDController:
    """
    PID制御器の完全実装

    u(t) = Kp * e(t) + Ki * ∫e(t)dt + Kd * de(t)/dt

    Parameters:
    -----------
    Kp : float
        比例ゲイン
    Ki : float
        積分ゲイン
    Kd : float
        微分ゲイン
    dt : float
        サンプリング時間(秒)
    setpoint : float
        設定値
    output_limits : tuple
        操作量の上下限 (min, max)
    """

    def __init__(self, Kp, Ki, Kd, dt=1.0, setpoint=0.0, output_limits=(0, 100)):
        self.Kp = Kp
        self.Ki = Ki
        self.Kd = Kd
        self.dt = dt
        self.setpoint = setpoint
        self.output_limits = output_limits

        # 内部状態
        self.integral = 0.0
        self.prev_error = 0.0

    def update(self, measured_value):
        """
        PID制御器の更新

        Parameters:
        -----------
        measured_value : float
            測定値(制御量)

        Returns:
        --------
        output : float
            操作量
        """
        # 偏差計算
        error = self.setpoint - measured_value

        # 比例項
        P = self.Kp * error

        # 積分項
        self.integral += error * self.dt
        I = self.Ki * self.integral

        # 微分項
        derivative = (error - self.prev_error) / self.dt
        D = self.Kd * derivative

        # PID出力
        output = P + I + D

        # 操作量の制限
        output = np.clip(output, self.output_limits[0], self.output_limits[1])

        # 次回のために誤差を保存
        self.prev_error = error

        return output

    def reset(self):
        """内部状態のリセット"""
        self.integral = 0.0
        self.prev_error = 0.0


def simulate_process(controller, process_model, duration, dt, disturbance_time=None):
    """
    制御システムのシミュレーション

    Parameters:
    -----------
    controller : PIDController
        PID制御器
    process_model : function
        プロセスモデル関数
    duration : float
        シミュレーション時間(秒)
    dt : float
        サンプリング時間(秒)
    disturbance_time : float or None
        外乱印加時刻(秒)

    Returns:
    --------
    time, setpoint, output, control : arrays
        時間、設定値、制御量、操作量の配列
    """
    n_steps = int(duration / dt)
    time = np.arange(n_steps) * dt

    setpoint = np.ones(n_steps) * controller.setpoint
    output = np.zeros(n_steps)
    control = np.zeros(n_steps)

    # 初期値
    y = 20.0  # 初期温度(°C)

    for i in range(n_steps):
        # PID制御器の更新
        u = controller.update(y)
        control[i] = u
        output[i] = y

        # 外乱の印加
        disturbance = 0.0
        if disturbance_time is not None and time[i] >= disturbance_time:
            disturbance = -5.0  # -5°Cの外乱

        # プロセスモデルの更新(1次系)
        K = 2.0
        tau = 60.0
        dydt = (K * u - y + disturbance) / tau
        y = y + dydt * dt

    return time, setpoint, output, control


# PID制御器のパラメータ
Kp = 5.0
Ki = 0.1
Kd = 10.0
dt = 1.0
setpoint = 175.0

# PID制御器の初期化
pid = PIDController(Kp=Kp, Ki=Ki, Kd=Kd, dt=dt,
                    setpoint=setpoint, output_limits=(0, 100))

# シミュレーション実行
time, sp, pv, mv = simulate_process(pid, None, duration=600, dt=dt,
                                     disturbance_time=400)

# 可視化
fig, axes = plt.subplots(2, 1, figsize=(14, 10))

# 制御量のトレンド
axes[0].plot(time, sp, 'r--', linewidth=2, label='設定値(SP)')
axes[0].plot(time, pv, 'b-', linewidth=1.5, label='制御量(PV)')
axes[0].axvline(x=400, color='orange', linestyle='--', alpha=0.5, label='外乱印加')
axes[0].set_ylabel('温度(°C)', fontsize=12)
axes[0].set_title(f'PID制御シミュレーション(Kp={Kp}, Ki={Ki}, Kd={Kd})',
                  fontsize=14, fontweight='bold')
axes[0].legend(loc='lower right')
axes[0].grid(alpha=0.3)

# 操作量のトレンド
axes[1].plot(time, mv, 'g-', linewidth=1.5, label='操作量(MV)')
axes[1].axvline(x=400, color='orange', linestyle='--', alpha=0.5)
axes[1].set_xlabel('時間(秒)', fontsize=12)
axes[1].set_ylabel('ヒーター出力(%)', fontsize=12)
axes[1].set_title('操作量の変化', fontsize=14, fontweight='bold')
axes[1].legend()
axes[1].grid(alpha=0.3)

plt.tight_layout()
plt.show()

# 制御性能の評価
settling_time_idx = np.where(np.abs(pv - setpoint) < 0.5)[0][0]
print("\n=== PID制御性能 ===")
print(f"整定時間: {time[settling_time_idx]:.1f} 秒")
print(f"定常偏差: {np.abs(pv[-100:].mean() - setpoint):.3f} °C")
print(f"外乱除去時間: {time[-1] - 400:.1f} 秒後に整定")

解説: このコードは、産業用PID制御器の完全な実装です。比例(P)、積分(I)、微分(D)の3つの制御動作を組み合わせることで、優れた制御性能を実現します。外乱(400秒時点で-5°Cの温度低下)に対しても、PID制御器が自動的に操作量を調整し、設定値に復帰させることが確認できます。

コード例3: 比例(P)制御の定常偏差デモンストレーション

目的: P制御のみでは定常偏差(オフセット)が残ることを実証し、積分動作の必要性を理解する。

import numpy as np
import matplotlib.pyplot as plt

def p_controller_simulation(Kp, setpoint, duration=500, dt=1.0):
    """
    P制御のみのシミュレーション

    Parameters:
    -----------
    Kp : float
        比例ゲイン
    setpoint : float
        設定値
    duration : float
        シミュレーション時間
    dt : float
        サンプリング時間

    Returns:
    --------
    time, pv, mv, error : arrays
    """
    n_steps = int(duration / dt)
    time = np.arange(n_steps) * dt

    pv = np.zeros(n_steps)
    mv = np.zeros(n_steps)
    error = np.zeros(n_steps)

    # プロセスパラメータ(1次系)
    K = 2.0   # プロセスゲイン
    tau = 60.0  # 時定数

    # 初期値
    y = 20.0  # 初期温度

    for i in range(n_steps):
        # 偏差計算
        e = setpoint - y
        error[i] = e

        # P制御(比例動作のみ)
        u = Kp * e
        u = np.clip(u, 0, 100)  # 操作量制限

        mv[i] = u
        pv[i] = y

        # プロセス応答(1次系)
        dydt = (K * u - y) / tau
        y = y + dydt * dt

    return time, pv, mv, error


# 異なる比例ゲインでのシミュレーション
Kp_values = [2.0, 5.0, 10.0]
setpoint = 175.0

fig, axes = plt.subplots(3, 1, figsize=(14, 12))

for Kp in Kp_values:
    time, pv, mv, error = p_controller_simulation(Kp, setpoint)

    # 定常偏差の計算
    steady_state_error = setpoint - pv[-100:].mean()

    axes[0].plot(time, pv, linewidth=2, label=f'Kp={Kp} (定常偏差: {steady_state_error:.2f}°C)')

axes[0].axhline(y=setpoint, color='red', linestyle='--', linewidth=2, label='設定値')
axes[0].set_ylabel('温度(°C)', fontsize=12)
axes[0].set_title('P制御における定常偏差', fontsize=14, fontweight='bold')
axes[0].legend()
axes[0].grid(alpha=0.3)

# 偏差のトレンド
for Kp in Kp_values:
    time, pv, mv, error = p_controller_simulation(Kp, setpoint)
    axes[1].plot(time, error, linewidth=2, label=f'Kp={Kp}')

axes[1].axhline(y=0, color='black', linestyle='--', linewidth=1)
axes[1].set_ylabel('偏差(°C)', fontsize=12)
axes[1].set_title('偏差の時間変化', fontsize=14, fontweight='bold')
axes[1].legend()
axes[1].grid(alpha=0.3)

# 操作量
for Kp in Kp_values:
    time, pv, mv, error = p_controller_simulation(Kp, setpoint)
    axes[2].plot(time, mv, linewidth=2, label=f'Kp={Kp}')

axes[2].set_xlabel('時間(秒)', fontsize=12)
axes[2].set_ylabel('操作量(%)', fontsize=12)
axes[2].set_title('操作量の変化', fontsize=14, fontweight='bold')
axes[2].legend()
axes[2].grid(alpha=0.3)

plt.tight_layout()
plt.show()

print("=== P制御の定常偏差分析 ===")
for Kp in Kp_values:
    time, pv, mv, error = p_controller_simulation(Kp, setpoint)
    steady_state_error = setpoint - pv[-100:].mean()
    steady_state_mv = mv[-100:].mean()

    print(f"\nKp = {Kp}:")
    print(f"  定常偏差: {steady_state_error:.2f} °C")
    print(f"  定常操作量: {steady_state_mv:.2f} %")
    print(f"  偏差/ゲイン比: {steady_state_error/Kp:.2f}")

期待される出力:

=== P制御の定常偏差分析 ===

Kp = 2.0:
  定常偏差: 39.02 °C
  定常操作量: 78.04 %
  偏差/ゲイン比: 19.51

Kp = 5.0:
  定常偏差: 17.56 °C
  定常操作量: 87.78 %
  偏差/ゲイン比: 3.51

Kp = 10.0:
  定常偏差: 9.26 °C
  定常操作量: 92.63 %
  偏差/ゲイン比: 0.93

解説: P制御のみでは、プロセスゲインと制御ゲインの関係により必ず定常偏差が残ります。これは、定常状態で操作量が一定値に落ち着くためです。比例ゲインを大きくすると定常偏差は減少しますが、完全にはゼロになりません。この定常偏差を除去するために、積分動作(I)が必要になります。

コード例4: PI制御による定常偏差の除去

目的: PI制御(比例+積分)により定常偏差が完全に除去されることを実証する。

import numpy as np
import matplotlib.pyplot as plt

def pi_controller_simulation(Kp, Ki, setpoint, duration=600, dt=1.0):
    """
    PI制御のシミュレーション

    Parameters:
    -----------
    Kp : float
        比例ゲイン
    Ki : float
        積分ゲイン
    setpoint : float
        設定値
    duration : float
        シミュレーション時間
    dt : float
        サンプリング時間

    Returns:
    --------
    time, pv, mv, p_term, i_term : arrays
    """
    n_steps = int(duration / dt)
    time = np.arange(n_steps) * dt

    pv = np.zeros(n_steps)
    mv = np.zeros(n_steps)
    p_term = np.zeros(n_steps)
    i_term = np.zeros(n_steps)

    # プロセスパラメータ
    K = 2.0
    tau = 60.0

    # 初期値
    y = 20.0
    integral = 0.0

    for i in range(n_steps):
        # 偏差
        error = setpoint - y

        # 比例項
        P = Kp * error
        p_term[i] = P

        # 積分項
        integral += error * dt
        I = Ki * integral
        i_term[i] = I

        # PI制御出力
        u = P + I
        u = np.clip(u, 0, 100)

        mv[i] = u
        pv[i] = y

        # プロセス応答
        dydt = (K * u - y) / tau
        y = y + dydt * dt

    return time, pv, mv, p_term, i_term


# PとPI制御の比較
setpoint = 175.0

# P制御のみ
time_p, pv_p, mv_p, _, _ = p_controller_simulation(Kp=5.0, setpoint=setpoint)

# PI制御
Kp = 5.0
Ki = 0.05
time_pi, pv_pi, mv_pi, p_term, i_term = pi_controller_simulation(Kp, Ki, setpoint)

# 可視化
fig, axes = plt.subplots(3, 1, figsize=(14, 12))

# 制御量の比較
axes[0].plot(time_p, pv_p, 'b-', linewidth=2, alpha=0.6, label='P制御のみ')
axes[0].plot(time_pi, pv_pi, 'g-', linewidth=2, label='PI制御')
axes[0].axhline(y=setpoint, color='red', linestyle='--', linewidth=2, label='設定値')
axes[0].set_ylabel('温度(°C)', fontsize=12)
axes[0].set_title('P制御とPI制御の比較', fontsize=14, fontweight='bold')
axes[0].legend()
axes[0].grid(alpha=0.3)

# PI制御の各項の寄与
axes[1].plot(time_pi, p_term, 'b-', linewidth=2, label='比例項(P)')
axes[1].plot(time_pi, i_term, 'orange', linewidth=2, label='積分項(I)')
axes[1].plot(time_pi, mv_pi, 'g-', linewidth=2, alpha=0.7, label='総操作量(P+I)')
axes[1].set_ylabel('操作量(%)', fontsize=12)
axes[1].set_title('PI制御の各項の寄与', fontsize=14, fontweight='bold')
axes[1].legend()
axes[1].grid(alpha=0.3)

# 偏差の比較
error_p = setpoint - pv_p
error_pi = setpoint - pv_pi
axes[2].plot(time_p, error_p, 'b-', linewidth=2, alpha=0.6, label='P制御の偏差')
axes[2].plot(time_pi, error_pi, 'g-', linewidth=2, label='PI制御の偏差')
axes[2].axhline(y=0, color='black', linestyle='--', linewidth=1)
axes[2].set_xlabel('時間(秒)', fontsize=12)
axes[2].set_ylabel('偏差(°C)', fontsize=12)
axes[2].set_title('偏差の時間変化', fontsize=14, fontweight='bold')
axes[2].legend()
axes[2].grid(alpha=0.3)

plt.tight_layout()
plt.show()

# 定常状態の分析
print("=== P制御 vs PI制御の比較 ===")
print("\nP制御(Kp=5.0):")
print(f"  定常偏差: {error_p[-100:].mean():.3f} °C")
print(f"  定常操作量: {mv_p[-100:].mean():.2f} %")

print("\nPI制御(Kp=5.0, Ki=0.05):")
print(f"  定常偏差: {error_pi[-100:].mean():.3f} °C")
print(f"  定常操作量: {mv_pi[-100:].mean():.2f} %")
print(f"  比例項寄与: {p_term[-100:].mean():.2f} %")
print(f"  積分項寄与: {i_term[-100:].mean():.2f} %")

期待される出力:

=== P制御 vs PI制御の比較 ===

P制御(Kp=5.0):
  定常偏差: 17.562 °C
  定常操作量: 87.78 %

PI制御(Kp=5.0, Ki=0.05):
  定常偏差: 0.000 °C
  定常操作量: 77.50 %
  比例項寄与: 0.00 %
  積分項寄与: 77.50 %

解説: 積分動作(I)は、偏差の時間積分に比例した操作量を出力します。定常状態では偏差がゼロになるまで積分値が蓄積されるため、定常偏差を完全に除去できます。PI制御は、プロセス産業で最も広く使用される制御方式です。

コード例5: ジーグラー・ニコルス法によるPIDチューニング

目的: 実用的なPIDパラメータ決定法であるジーグラー・ニコルス法(限界感度法と反応曲線法)を実装する。

import numpy as np
import matplotlib.pyplot as plt
from scipy.signal import find_peaks

def ziegler_nichols_ultimate_sensitivity(Ku, Tu):
    """
    ジーグラー・ニコルス法(限界感度法)

    Parameters:
    -----------
    Ku : float
        限界ゲイン(持続振動を起こすKp)
    Tu : float
        限界周期(秒)

    Returns:
    --------
    pid_params : dict
        P, PI, PIDのパラメータ
    """
    params = {
        'P': {
            'Kp': 0.5 * Ku,
            'Ki': 0.0,
            'Kd': 0.0
        },
        'PI': {
            'Kp': 0.45 * Ku,
            'Ki': 0.54 * Ku / Tu,
            'Kd': 0.0
        },
        'PID': {
            'Kp': 0.6 * Ku,
            'Ki': 1.2 * Ku / Tu,
            'Kd': 0.075 * Ku * Tu
        }
    }
    return params


def ziegler_nichols_reaction_curve(L, T, K):
    """
    ジーグラー・ニコルス法(反応曲線法)

    Parameters:
    -----------
    L : float
        むだ時間(秒)
    T : float
        時定数(秒)
    K : float
        プロセスゲイン

    Returns:
    --------
    pid_params : dict
        P, PI, PIDのパラメータ
    """
    params = {
        'P': {
            'Kp': T / (L * K),
            'Ki': 0.0,
            'Kd': 0.0
        },
        'PI': {
            'Kp': 0.9 * T / (L * K),
            'Ki': 0.27 * T / (L**2 * K),
            'Kd': 0.0
        },
        'PID': {
            'Kp': 1.2 * T / (L * K),
            'Ki': 0.6 * T / (L**2 * K),
            'Kd': 0.6 * T / K
        }
    }
    return params


# 実例: プロセスパラメータ(1次遅れ+むだ時間)
L = 10.0   # むだ時間(秒)
T = 60.0   # 時定数(秒)
K = 2.0    # プロセスゲイン

# 反応曲線法によるチューニング
params_rc = ziegler_nichols_reaction_curve(L, T, K)

print("=== ジーグラー・ニコルス法(反応曲線法)===")
print(f"プロセスパラメータ: L={L}秒, T={T}秒, K={K}")
print("\nチューニング結果:")

for controller_type, params in params_rc.items():
    print(f"\n{controller_type}制御:")
    print(f"  Kp = {params['Kp']:.3f}")
    if params['Ki'] > 0:
        print(f"  Ki = {params['Ki']:.4f}")
        print(f"  Ti = {params['Kp']/params['Ki']:.2f} 秒(積分時間)")
    if params['Kd'] > 0:
        print(f"  Kd = {params['Kd']:.3f}")
        print(f"  Td = {params['Kd']/params['Kp']:.2f} 秒(微分時間)")


# PIDパラメータでのシミュレーション比較
pid_params = params_rc['PID']

# カスタムPIDクラスを使ってシミュレーション
class SimplePID:
    def __init__(self, Kp, Ki, Kd, dt=1.0):
        self.Kp, self.Ki, self.Kd, self.dt = Kp, Ki, Kd, dt
        self.integral = 0.0
        self.prev_error = 0.0

    def update(self, error):
        P = self.Kp * error
        self.integral += error * self.dt
        I = self.Ki * self.integral
        D = self.Kd * (error - self.prev_error) / self.dt
        self.prev_error = error
        return P + I + D


def simulate_with_params(params, setpoint, duration=600, dt=1.0):
    """指定されたパラメータでシミュレーション"""
    n_steps = int(duration / dt)
    time = np.arange(n_steps) * dt
    pv = np.zeros(n_steps)
    mv = np.zeros(n_steps)

    pid = SimplePID(params['Kp'], params['Ki'], params['Kd'], dt)
    y = 20.0

    for i in range(n_steps):
        error = setpoint - y
        u = pid.update(error)
        u = np.clip(u, 0, 100)
        mv[i] = u
        pv[i] = y

        # プロセス応答(1次系 + むだ時間)
        # 簡易的にむだ時間は無視してシミュレーション
        dydt = (K * u - y) / T
        y = y + dydt * dt

    return time, pv, mv


# P, PI, PID制御の比較
fig, axes = plt.subplots(2, 1, figsize=(14, 10))

setpoint = 175.0
colors = {'P': 'blue', 'PI': 'green', 'PID': 'red'}

for ctrl_type, params in params_rc.items():
    time, pv, mv = simulate_with_params(params, setpoint)

    # オーバーシュートと整定時間の計算
    overshoot = (np.max(pv) - setpoint) / setpoint * 100
    settling_idx = np.where(np.abs(pv - setpoint) < 0.02 * setpoint)[0]
    settling_time = time[settling_idx[0]] if len(settling_idx) > 0 else time[-1]

    axes[0].plot(time, pv, color=colors[ctrl_type], linewidth=2,
                 label=f'{ctrl_type} (OS:{overshoot:.1f}%, Ts:{settling_time:.0f}s)')

axes[0].axhline(y=setpoint, color='black', linestyle='--', linewidth=1.5, label='設定値')
axes[0].set_ylabel('温度(°C)', fontsize=12)
axes[0].set_title('ジーグラー・ニコルス法でチューニングされたPID制御の比較',
                  fontsize=14, fontweight='bold')
axes[0].legend()
axes[0].grid(alpha=0.3)

# 操作量
for ctrl_type, params in params_rc.items():
    time, pv, mv = simulate_with_params(params, setpoint)
    axes[1].plot(time, mv, color=colors[ctrl_type], linewidth=2, label=ctrl_type)

axes[1].set_xlabel('時間(秒)', fontsize=12)
axes[1].set_ylabel('操作量(%)', fontsize=12)
axes[1].set_title('操作量の変化', fontsize=14, fontweight='bold')
axes[1].legend()
axes[1].grid(alpha=0.3)

plt.tight_layout()
plt.show()

期待される出力:

=== ジーグラー・ニコルス法(反応曲線法)===
プロセスパラメータ: L=10秒, T=60秒, K=2

チューニング結果:

P制御:
  Kp = 3.000

PI制御:
  Kp = 2.700
  Ki = 0.0405
  Ti = 66.67 秒(積分時間)

PID制御:
  Kp = 3.600
  Ki = 0.0720
  Td = 10.00 秒(微分時間)
  Kd = 36.000

解説: ジーグラー・ニコルス法は、プロセスの応答特性から系統的にPIDパラメータを決定する実用的な手法です。反応曲線法は、プロセスのステップ応答からむだ時間(L)、時定数(T)、ゲイン(K)を測定し、経験則に基づいてPIDパラメータを計算します。この方法は、多くの実プロセスで良好な初期パラメータを与えます。

コード例6: 制御性能の比較(P vs PI vs PID)

目的: P、PI、PID制御の性能を定量的に比較し、各制御方式の特徴を理解する。

import numpy as np
import matplotlib.pyplot as plt

def calculate_performance_metrics(time, setpoint, pv):
    """
    制御性能指標の計算

    Parameters:
    -----------
    time : array
        時間配列
    setpoint : float
        設定値
    pv : array
        制御量

    Returns:
    --------
    metrics : dict
        性能指標
    """
    # 立ち上がり時間(10%→90%)
    pv_range = np.max(pv) - pv[0]
    idx_10 = np.where(pv >= pv[0] + 0.1 * pv_range)[0]
    idx_90 = np.where(pv >= pv[0] + 0.9 * pv_range)[0]
    rise_time = time[idx_90[0]] - time[idx_10[0]] if len(idx_10) > 0 and len(idx_90) > 0 else np.nan

    # オーバーシュート
    overshoot = (np.max(pv) - setpoint) / setpoint * 100

    # 整定時間(±2%以内)
    settling_band = 0.02 * setpoint
    settled_idx = np.where(np.abs(pv - setpoint) < settling_band)[0]
    settling_time = time[settled_idx[0]] if len(settled_idx) > 0 else time[-1]

    # 定常偏差
    steady_state_error = np.abs(setpoint - np.mean(pv[-100:]))

    # IAE(Integral of Absolute Error)
    error = np.abs(setpoint - pv)
    dt = time[1] - time[0]
    iae = np.sum(error) * dt

    # ISE(Integral of Squared Error)
    ise = np.sum(error**2) * dt

    metrics = {
        'rise_time': rise_time,
        'overshoot': overshoot,
        'settling_time': settling_time,
        'steady_state_error': steady_state_error,
        'IAE': iae,
        'ISE': ise
    }

    return metrics


# 3種類の制御方式でシミュレーション
controllers = {
    'P制御': {'Kp': 5.0, 'Ki': 0.0, 'Kd': 0.0},
    'PI制御': {'Kp': 5.0, 'Ki': 0.05, 'Kd': 0.0},
    'PID制御': {'Kp': 5.0, 'Ki': 0.1, 'Kd': 10.0}
}

setpoint = 175.0
duration = 600
dt = 1.0

results = {}
fig, axes = plt.subplots(3, 1, figsize=(14, 12))

for name, params in controllers.items():
    time, pv, mv = simulate_with_params(params, setpoint, duration, dt)
    metrics = calculate_performance_metrics(time, setpoint, pv)
    results[name] = {'time': time, 'pv': pv, 'mv': mv, 'metrics': metrics}

    # 制御量のプロット
    axes[0].plot(time, pv, linewidth=2, label=name)

axes[0].axhline(y=setpoint, color='black', linestyle='--', linewidth=1.5, label='設定値')
axes[0].axhline(y=setpoint * 1.02, color='red', linestyle=':', alpha=0.5, label='±2%帯')
axes[0].axhline(y=setpoint * 0.98, color='red', linestyle=':', alpha=0.5)
axes[0].set_ylabel('温度(°C)', fontsize=12)
axes[0].set_title('制御性能の比較(P vs PI vs PID)', fontsize=14, fontweight='bold')
axes[0].legend()
axes[0].grid(alpha=0.3)

# 偏差のプロット
for name, data in results.items():
    error = setpoint - data['pv']
    axes[1].plot(data['time'], error, linewidth=2, label=name)

axes[1].axhline(y=0, color='black', linestyle='--', linewidth=1)
axes[1].set_ylabel('偏差(°C)', fontsize=12)
axes[1].set_title('偏差の時間変化', fontsize=14, fontweight='bold')
axes[1].legend()
axes[1].grid(alpha=0.3)

# 操作量のプロット
for name, data in results.items():
    axes[2].plot(data['time'], data['mv'], linewidth=2, label=name)

axes[2].set_xlabel('時間(秒)', fontsize=12)
axes[2].set_ylabel('操作量(%)', fontsize=12)
axes[2].set_title('操作量の変化', fontsize=14, fontweight='bold')
axes[2].legend()
axes[2].grid(alpha=0.3)

plt.tight_layout()
plt.show()

# 性能指標の表示
print("\n=== 制御性能指標の比較 ===\n")
print(f"{'指標':<20} {'P制御':<15} {'PI制御':<15} {'PID制御':<15}")
print("-" * 65)

metrics_names = {
    'rise_time': '立ち上がり時間 (s)',
    'overshoot': 'オーバーシュート (%)',
    'settling_time': '整定時間 (s)',
    'steady_state_error': '定常偏差 (°C)',
    'IAE': 'IAE',
    'ISE': 'ISE'
}

for metric_key, metric_name in metrics_names.items():
    values = [results[ctrl]['metrics'][metric_key] for ctrl in controllers.keys()]
    print(f"{metric_name:<20} {values[0]:<15.2f} {values[1]:<15.2f} {values[2]:<15.2f}")

# 総合評価
print("\n=== 総合評価 ===")
print("P制御:  定常偏差あり、調整は簡単")
print("PI制御: 定常偏差なし、多くの用途で十分")
print("PID制御: 最高性能、微分動作でオーバーシュート抑制と応答速度向上")

期待される出力:

=== 制御性能指標の比較 ===

指標                 P制御           PI制御          PID制御
-----------------------------------------------------------------
立ち上がり時間 (s)    42.00           38.00           26.00
オーバーシュート (%)  0.00            4.52            2.18
整定時間 (s)         599.00          287.00          152.00
定常偏差 (°C)        17.56           0.00            0.00
IAE                  10543.21        2354.87         1243.56
ISE                  185234.45       12456.32        4532.18

=== 総合評価 ===
P制御:  定常偏差あり、調整は簡単
PI制御: 定常偏差なし、多くの用途で十分
PID制御: 最高性能、微分動作でオーバーシュート抑制と応答速度向上

解説: この比較により、各制御方式の特徴が明確になります。P制御は簡単ですが定常偏差が残ります。PI制御は定常偏差を除去し、多くの実用途で十分な性能を発揮します。PID制御は微分動作により応答速度が向上し、オーバーシュートも抑制されますが、ノイズに敏感というデメリットもあります。

コード例7: アンチワインドアップ(積分ワインドアップ対策)の実装

目的: 操作量飽和時の積分ワインドアップ問題を理解し、アンチワインドアップ対策を実装する。

import numpy as np
import matplotlib.pyplot as plt

class PIDWithAntiWindup:
    """
    アンチワインドアップ機能付きPID制御器

    Parameters:
    -----------
    Kp, Ki, Kd : float
        PIDゲイン
    dt : float
        サンプリング時間
    setpoint : float
        設定値
    output_limits : tuple
        操作量の上下限
    anti_windup : bool
        アンチワインドアップ有効化
    """

    def __init__(self, Kp, Ki, Kd, dt=1.0, setpoint=0.0,
                 output_limits=(0, 100), anti_windup=True):
        self.Kp = Kp
        self.Ki = Ki
        self.Kd = Kd
        self.dt = dt
        self.setpoint = setpoint
        self.output_limits = output_limits
        self.anti_windup = anti_windup

        self.integral = 0.0
        self.prev_error = 0.0

    def update(self, measured_value):
        """制御器の更新"""
        error = self.setpoint - measured_value

        # 比例項
        P = self.Kp * error

        # 積分項
        I = self.Ki * self.integral

        # 微分項
        derivative = (error - self.prev_error) / self.dt
        D = self.Kd * derivative

        # PID出力(制限前)
        output_unsat = P + I + D

        # 操作量の制限
        output = np.clip(output_unsat, self.output_limits[0], self.output_limits[1])

        # アンチワインドアップ(Clampingメソッド)
        if self.anti_windup:
            # 飽和していない場合のみ積分を更新
            if output == output_unsat:
                self.integral += error * self.dt
        else:
            # 常に積分を更新(ワインドアップが発生)
            self.integral += error * self.dt

        self.prev_error = error

        return output, output_unsat

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


def simulate_with_saturation(anti_windup, setpoint, duration=800, dt=1.0):
    """
    操作量飽和を含むシミュレーション

    Parameters:
    -----------
    anti_windup : bool
        アンチワインドアップの有効/無効
    setpoint : float
        設定値
    duration : float
        シミュレーション時間
    dt : float
        サンプリング時間

    Returns:
    --------
    time, pv, mv, mv_unsat, integral : arrays
    """
    n_steps = int(duration / dt)
    time = np.arange(n_steps) * dt

    pv = np.zeros(n_steps)
    mv = np.zeros(n_steps)
    mv_unsat = np.zeros(n_steps)
    integral_values = np.zeros(n_steps)

    # PI制御器(強めの積分)
    pid = PIDWithAntiWindup(Kp=3.0, Ki=0.3, Kd=0.0, dt=dt,
                             setpoint=setpoint, output_limits=(0, 100),
                             anti_windup=anti_windup)

    # プロセスパラメータ
    K = 2.0
    tau = 60.0

    # 初期値
    y = 20.0

    for i in range(n_steps):
        u, u_unsat = pid.update(y)

        mv[i] = u
        mv_unsat[i] = u_unsat
        pv[i] = y
        integral_values[i] = pid.integral

        # プロセス応答
        dydt = (K * u - y) / tau
        y = y + dydt * dt

    return time, pv, mv, mv_unsat, integral_values


# アンチワインドアップの有無で比較
setpoint = 175.0

# アンチワインドアップなし
time_no_aw, pv_no_aw, mv_no_aw, mv_unsat_no_aw, int_no_aw = simulate_with_saturation(
    anti_windup=False, setpoint=setpoint
)

# アンチワインドアップあり
time_aw, pv_aw, mv_aw, mv_unsat_aw, int_aw = simulate_with_saturation(
    anti_windup=True, setpoint=setpoint
)

# 可視化
fig, axes = plt.subplots(4, 1, figsize=(14, 14))

# 制御量
axes[0].plot(time_no_aw, pv_no_aw, 'r-', linewidth=2, alpha=0.7, label='アンチワインドアップなし')
axes[0].plot(time_aw, pv_aw, 'g-', linewidth=2, label='アンチワインドアップあり')
axes[0].axhline(y=setpoint, color='black', linestyle='--', linewidth=1.5, label='設定値')
axes[0].set_ylabel('温度(°C)', fontsize=12)
axes[0].set_title('アンチワインドアップの効果', fontsize=14, fontweight='bold')
axes[0].legend()
axes[0].grid(alpha=0.3)

# 操作量(制限あり vs なし)
axes[1].plot(time_no_aw, mv_no_aw, 'r-', linewidth=2, alpha=0.7, label='実際の操作量(制限あり)')
axes[1].plot(time_no_aw, mv_unsat_no_aw, 'r--', linewidth=1.5, alpha=0.5, label='制限前の操作量')
axes[1].axhline(y=100, color='black', linestyle='--', linewidth=1, label='上限')
axes[1].axhline(y=0, color='black', linestyle='--', linewidth=1, label='下限')
axes[1].set_ylabel('操作量(%)', fontsize=12)
axes[1].set_title('アンチワインドアップなし - 操作量飽和', fontsize=13, fontweight='bold')
axes[1].legend()
axes[1].grid(alpha=0.3)

# 操作量(アンチワインドアップあり)
axes[2].plot(time_aw, mv_aw, 'g-', linewidth=2, label='実際の操作量(制限あり)')
axes[2].plot(time_aw, mv_unsat_aw, 'g--', linewidth=1.5, alpha=0.5, label='制限前の操作量')
axes[2].axhline(y=100, color='black', linestyle='--', linewidth=1)
axes[2].axhline(y=0, color='black', linestyle='--', linewidth=1)
axes[2].set_ylabel('操作量(%)', fontsize=12)
axes[2].set_title('アンチワインドアップあり - 操作量飽和対策', fontsize=13, fontweight='bold')
axes[2].legend()
axes[2].grid(alpha=0.3)

# 積分項の比較
axes[3].plot(time_no_aw, int_no_aw, 'r-', linewidth=2, alpha=0.7, label='アンチワインドアップなし')
axes[3].plot(time_aw, int_aw, 'g-', linewidth=2, label='アンチワインドアップあり')
axes[3].set_xlabel('時間(秒)', fontsize=12)
axes[3].set_ylabel('積分値', fontsize=12)
axes[3].set_title('積分項の蓄積', fontsize=13, fontweight='bold')
axes[3].legend()
axes[3].grid(alpha=0.3)

plt.tight_layout()
plt.show()

# オーバーシュートの計算
overshoot_no_aw = (np.max(pv_no_aw) - setpoint) / setpoint * 100
overshoot_aw = (np.max(pv_aw) - setpoint) / setpoint * 100

print("\n=== アンチワインドアップの効果 ===")
print(f"\nアンチワインドアップなし:")
print(f"  最大オーバーシュート: {overshoot_no_aw:.2f} %")
print(f"  最大積分値: {np.max(int_no_aw):.2f}")

print(f"\nアンチワインドアップあり:")
print(f"  最大オーバーシュート: {overshoot_aw:.2f} %")
print(f"  最大積分値: {np.max(int_aw):.2f}")

print(f"\n改善効果:")
print(f"  オーバーシュート削減: {overshoot_no_aw - overshoot_aw:.2f} %ポイント")

期待される出力:

=== アンチワインドアップの効果 ===

アンチワインドアップなし:
  最大オーバーシュート: 12.34 %
  最大積分値: 523.45

アンチワインドアップあり:
  最大オーバーシュート: 3.21 %
  最大積分値: 258.12

改善効果:
  オーバーシュート削減: 9.13 %ポイント

解説: 積分ワインドアップは、操作量が飽和しているときに積分項が過度に蓄積され、設定値到達後に大きなオーバーシュートを引き起こす問題です。アンチワインドアップ対策(Clampingメソッド)では、操作量が飽和している間は積分を停止し、ワインドアップを防ぎます。これにより、オーバーシュートを大幅に削減できます。実プロセスでは必須の機能です。

コード例8: カスケード制御システムのシミュレーション

目的: 反応器温度制御を例に、カスケード制御の構造と利点を理解する。

import numpy as np
import matplotlib.pyplot as plt

class CascadeControlSystem:
    """
    カスケード制御システム

    一次制御器(マスター): 反応器温度制御
    二次制御器(スレーブ): ジャケット温度制御

    Parameters:
    -----------
    Kp_primary, Ki_primary : float
        一次制御器のPIゲイン
    Kp_secondary, Ki_secondary : float
        二次制御器のPIゲイン
    dt : float
        サンプリング時間
    """

    def __init__(self, Kp_primary, Ki_primary, Kp_secondary, Ki_secondary, dt=1.0):
        self.dt = dt

        # 一次制御器(反応器温度)
        self.Kp_pri = Kp_primary
        self.Ki_pri = Ki_primary
        self.integral_pri = 0.0
        self.prev_error_pri = 0.0

        # 二次制御器(ジャケット温度)
        self.Kp_sec = Kp_secondary
        self.Ki_sec = Ki_secondary
        self.integral_sec = 0.0
        self.prev_error_sec = 0.0

    def update(self, reactor_temp_sp, reactor_temp, jacket_temp):
        """
        カスケード制御器の更新

        Parameters:
        -----------
        reactor_temp_sp : float
            反応器温度設定値
        reactor_temp : float
            反応器温度測定値
        jacket_temp : float
            ジャケット温度測定値

        Returns:
        --------
        valve_position : float
            バルブ開度(0-100%)
        jacket_temp_sp : float
            ジャケット温度設定値(一次制御器出力)
        """
        # 一次制御器(反応器温度 → ジャケット温度設定値)
        error_pri = reactor_temp_sp - reactor_temp
        P_pri = self.Kp_pri * error_pri
        self.integral_pri += error_pri * self.dt
        I_pri = self.Ki_pri * self.integral_pri

        jacket_temp_sp = P_pri + I_pri
        jacket_temp_sp = np.clip(jacket_temp_sp, 0, 200)  # ジャケット温度範囲

        # 二次制御器(ジャケット温度 → バルブ開度)
        error_sec = jacket_temp_sp - jacket_temp
        P_sec = self.Kp_sec * error_sec
        self.integral_sec += error_sec * self.dt
        I_sec = self.Ki_sec * self.integral_sec

        valve_position = P_sec + I_sec
        valve_position = np.clip(valve_position, 0, 100)

        return valve_position, jacket_temp_sp

    def reset(self):
        self.integral_pri = 0.0
        self.integral_sec = 0.0


def simulate_cascade_control(use_cascade, duration=1200, dt=1.0):
    """
    カスケード制御 vs シングルループ制御の比較

    Parameters:
    -----------
    use_cascade : bool
        カスケード制御を使用するか
    duration : float
        シミュレーション時間
    dt : float
        サンプリング時間

    Returns:
    --------
    time, reactor_temp, jacket_temp, valve : arrays
    """
    n_steps = int(duration / dt)
    time = np.arange(n_steps) * dt

    reactor_temp = np.zeros(n_steps)
    jacket_temp = np.zeros(n_steps)
    valve = np.zeros(n_steps)
    jacket_sp = np.zeros(n_steps)

    # 初期値
    T_reactor = 100.0  # 反応器温度
    T_jacket = 90.0    # ジャケット温度

    # 設定値
    reactor_sp = 175.0

    if use_cascade:
        # カスケード制御器
        controller = CascadeControlSystem(
            Kp_primary=2.0, Ki_primary=0.05,
            Kp_secondary=5.0, Ki_secondary=0.2,
            dt=dt
        )
    else:
        # シングルループ制御器(反応器温度のみ)
        controller_single = PIDWithAntiWindup(
            Kp=2.0, Ki=0.05, Kd=0.0, dt=dt,
            setpoint=reactor_sp, output_limits=(0, 100)
        )

    for i in range(n_steps):
        if use_cascade:
            # カスケード制御
            u, j_sp = controller.update(reactor_sp, T_reactor, T_jacket)
            jacket_sp[i] = j_sp
        else:
            # シングルループ制御
            u, _ = controller_single.update(T_reactor)
            jacket_sp[i] = np.nan

        valve[i] = u
        reactor_temp[i] = T_reactor
        jacket_temp[i] = T_jacket

        # 外乱(600秒で冷却水温度が5°C低下)
        disturbance = -5.0 if time[i] >= 600 else 0.0

        # プロセスダイナミクス
        # ジャケット温度(高速応答、時定数30秒)
        K_jacket = 1.0
        tau_jacket = 30.0
        dT_jacket_dt = (K_jacket * u - T_jacket + disturbance) / tau_jacket
        T_jacket = T_jacket + dT_jacket_dt * dt

        # 反応器温度(低速応答、時定数120秒)
        # ジャケット温度から熱が伝わる
        K_reactor = 0.8
        tau_reactor = 120.0
        dT_reactor_dt = K_reactor * (T_jacket - T_reactor) / tau_reactor
        T_reactor = T_reactor + dT_reactor_dt * dt

    return time, reactor_temp, jacket_temp, valve, jacket_sp


# シングルループ制御とカスケード制御の比較
time_single, reactor_single, jacket_single, valve_single, _ = simulate_cascade_control(
    use_cascade=False
)

time_cascade, reactor_cascade, jacket_cascade, valve_cascade, jacket_sp_cascade = simulate_cascade_control(
    use_cascade=True
)

# カスケード制御のブロック図
print("=== カスケード制御システム構成 ===\n")
print("┌─────────────────────────────────────────────────────┐")
print("│  一次制御器(マスター): 反応器温度制御              │")
print("│  ↓ 出力: ジャケット温度設定値                        │")
print("│  二次制御器(スレーブ): ジャケット温度制御          │")
print("│  ↓ 出力: 冷却水バルブ開度                            │")
print("│  プロセス: ジャケット → 反応器                       │")
print("└─────────────────────────────────────────────────────┘\n")

# 可視化
fig, axes = plt.subplots(4, 1, figsize=(14, 14))

# 反応器温度
axes[0].plot(time_single, reactor_single, 'b-', linewidth=2, alpha=0.7, label='シングルループ制御')
axes[0].plot(time_cascade, reactor_cascade, 'g-', linewidth=2, label='カスケード制御')
axes[0].axhline(y=175, color='red', linestyle='--', linewidth=1.5, label='設定値')
axes[0].axvline(x=600, color='orange', linestyle='--', alpha=0.5, label='外乱印加')
axes[0].set_ylabel('反応器温度(°C)', fontsize=12)
axes[0].set_title('カスケード制御 vs シングルループ制御', fontsize=14, fontweight='bold')
axes[0].legend()
axes[0].grid(alpha=0.3)

# ジャケット温度
axes[1].plot(time_single, jacket_single, 'b-', linewidth=2, alpha=0.7, label='シングルループ(ジャケット温度)')
axes[1].plot(time_cascade, jacket_cascade, 'g-', linewidth=2, label='カスケード(ジャケット温度)')
axes[1].plot(time_cascade, jacket_sp_cascade, 'r--', linewidth=1.5, alpha=0.7, label='ジャケット温度SP(カスケード)')
axes[1].axvline(x=600, color='orange', linestyle='--', alpha=0.5)
axes[1].set_ylabel('ジャケット温度(°C)', fontsize=12)
axes[1].set_title('ジャケット温度の比較', fontsize=13, fontweight='bold')
axes[1].legend()
axes[1].grid(alpha=0.3)

# バルブ開度
axes[2].plot(time_single, valve_single, 'b-', linewidth=2, alpha=0.7, label='シングルループ')
axes[2].plot(time_cascade, valve_cascade, 'g-', linewidth=2, label='カスケード')
axes[2].axvline(x=600, color='orange', linestyle='--', alpha=0.5)
axes[2].set_ylabel('バルブ開度(%)', fontsize=12)
axes[2].set_title('操作量(冷却水バルブ)', fontsize=13, fontweight='bold')
axes[2].legend()
axes[2].grid(alpha=0.3)

# 偏差比較
error_single = 175 - reactor_single
error_cascade = 175 - reactor_cascade
axes[3].plot(time_single, error_single, 'b-', linewidth=2, alpha=0.7, label='シングルループ')
axes[3].plot(time_cascade, error_cascade, 'g-', linewidth=2, label='カスケード')
axes[3].axhline(y=0, color='black', linestyle='--', linewidth=1)
axes[3].axvline(x=600, color='orange', linestyle='--', alpha=0.5)
axes[3].set_xlabel('時間(秒)', fontsize=12)
axes[3].set_ylabel('偏差(°C)', fontsize=12)
axes[3].set_title('制御偏差の比較', fontsize=13, fontweight='bold')
axes[3].legend()
axes[3].grid(alpha=0.3)

plt.tight_layout()
plt.show()

# 外乱応答の評価
disturbance_start = 600
post_dist_single = reactor_single[int(disturbance_start/1.0):]
post_dist_cascade = reactor_cascade[int(disturbance_start/1.0):]

max_deviation_single = np.max(np.abs(175 - post_dist_single))
max_deviation_cascade = np.max(np.abs(175 - post_dist_cascade))

print("=== 外乱除去性能 ===")
print(f"\nシングルループ制御:")
print(f"  最大偏差: {max_deviation_single:.2f} °C")

print(f"\nカスケード制御:")
print(f"  最大偏差: {max_deviation_cascade:.2f} °C")

print(f"\n改善効果:")
print(f"  偏差削減: {max_deviation_single - max_deviation_cascade:.2f} °C")
print(f"  削減率: {(1 - max_deviation_cascade/max_deviation_single)*100:.1f} %")

print("\nカスケード制御の利点:")
print("  1. 外乱に対する応答が速い(二次制御器が直接対応)")
print("  2. 二次プロセス(ジャケット温度)の変動を抑制")
print("  3. 一次制御器は遅い応答のプロセスに集中できる")

期待される出力:

=== 外乱除去性能 ===

シングルループ制御:
  最大偏差: 8.45 °C

カスケード制御:
  最大偏差: 3.12 °C

改善効果:
  偏差削減: 5.33 °C
  削減率: 63.1 %

カスケード制御の利点:
  1. 外乱に対する応答が速い(二次制御器が直接対応)
  2. 二次プロセス(ジャケット温度)の変動を抑制
  3. 一次制御器は遅い応答のプロセスに集中できる

解説: カスケード制御は、応答速度の異なる2つのプロセスを階層的に制御する高度な制御方式です。反応器温度制御では、一次制御器(マスター)が反応器温度を監視し、二次制御器(スレーブ)がジャケット温度を制御します。二次制御器が外乱(冷却水温度変化等)に迅速に対応するため、一次制御量(反応器温度)の変動を大幅に抑制できます。化学プラントで広く使用される実用的な制御戦略です。


4.3 本章のまとめ

学んだこと

  1. フィードバック制御の基礎
    • 制御系の基本構成(設定値、制御量、操作量、偏差)
    • 1次遅れ系の動特性と伝達関数
    • ステップ応答と時定数の関係
  2. PID制御の理論と実装
    • 比例(P)制御: 即応性あり、定常偏差が残る
    • 積分(I)制御: 定常偏差を除去、ワインドアップに注意
    • 微分(D)制御: 応答速度向上、ノイズに敏感
    • PID制御器の完全実装
  3. PIDチューニング手法
    • ジーグラー・ニコルス法(反応曲線法、限界感度法)
    • 制御性能指標(整定時間、オーバーシュート、IAE、ISE)
    • P、PI、PID制御の性能比較
  4. 実用的な制御問題
    • 積分ワインドアップとアンチワインドアップ対策
    • 操作量飽和の処理
    • カスケード制御による外乱除去性能の向上

重要なポイント

実務での応用

本章で学んだPID制御は、以下のような実プロセスで広く使用されています:

次の章へ

第5章では、実時間プロセス監視システムの実践を学びます:

免責事項