ファジー制御してみようの会

この記事は Stormworks Advent Calendar 2025 16 日目の記事です。

はじめに

ある目標値になるようにシステムを制御する方法にはいくつかの方法があります。 特に PID 制御は Stormworks 内の組み込みブロックにもあるように、とりあえず PID しておけばお手軽に対象を制御できます。

タイトルにあるファジー制御は「もう少し上げたい」「もう少し下げたい」といった人間的なあいまいな指示を使って制御をします。

ここではストームワークス上の小型船舶を一定速度に保つ速度制御をファジー制御を用いて制御する例を示します。

ファジー制御とは

ファジー制御は、人間のあいまいな判断を模倣して制御するアルゴリズムの一つです。 例えば人が車を一定速度で保とうとしたとき、目標速度よりも速度が低いときはアクセルを踏んで加速、逆に速い時はアクセルを緩めて減速することで速度を制御します。 この遅すぎるや速すぎるといった状態から、遅いときはどうする、速すぎる時はどうするといったルールをもとに、最終的なアクセルの踏み具合を決定していくのがざっくりとしたファジー制御の概要です。

主に以下の順で制御値を決定していきます。

  1. 状態を評価する
  2. ルールに基づき制御値を決定する

1. 状態を評価する

システムの状態がどの状態になっているかの適応度を決定します。 速度制御でいうと、速い、ちょうどいい、遅い といった3つの状態にどれだけ当てはまっているかメンバーシップ関数を使い評価します。 各メンバーシップ関数の出力から、どの状態なのかを決定します。

2. ルールに基づき制御方針を決める

  1. で評価した結果、どう制御したいかをあらかじめ決めたルールをもとに方針を決めます。

例えば以下のようなルールを定めたとします。

  • 速度が遅いならば、出力を上げる
  • 速度がちょうどよいならば、維持する
  • 速度が速いならば、出力を下げる

設定したルールごとにそのルールにどのぐらい当てはまるかの適応度と、出力値の重みをもとに最終的な操作量を決定します。

ファジー制御のメリットデメリット

ファジィ制御の特徴として以下のようなメリット、デメリットが上げられます。

メリット

  • 自然言語的な設計ができる
  • 制御対象のモデル化することなく、設計することができる
  • 比較的計算は軽い

デメリット

  • 安定性はルール設計に依存する
  • 調整にはログの取得や試行が必要になる場合がある

ある程度感覚的に制御ルールを作ることができるので、システムの状態がこの場合はこうするといった方針を決めることで制御系を作ることができます。 ルールを細かく用意することでより繊細な制御ができるようになります。 例えば加速は素早くしたいけど減速はゆっくりにしたいなど、目標値からの誤差の度合いによって制御方針を変えることが容易にできます。 しかし、設計したルールで本当に安定した制御ができるかどうかは保証できないため、ルール設計にはある程度は経験や勘に依存します。

制御対象

今回は以下の小型船の速度制御をします。

制御対象の小型船

  • 小型既成エンジン 1 機
  • エンジンRPSは 0 ~ 10 RPS程度を想定
  • 速度レンジ 0 ~ 40 knot

エンジンの RPS の制御は PID 制御を用い、目標 RPS をファジー制御によって制御します。 全体として2重ループ構造のうち外側をファジー制御します。

なお、以降測定した値はすべて風0%の条件で実験します。

ルールを決める

入力として速度誤差と、速度誤差の変化を入力として採用します。

速度誤差 v_error = current_speed - target_speed

  • -:遅い
  • +:速い

速度変化の誤差 dv = v_error - prev_v_error

  • -: 目標に近づいている
  • 0付近: 誤差が変わっていない
  • +: 目標から離れている

速度誤差の評価は5段階で評価。

  • NB: かなり遅い
  • NS: ちょっと遅い
  • Z: ちょうどいい
  • PS: ちょっと速い
  • PB: かなり速い

これを簡単化のために3段階にまとめました。

  • L: NB と NS のうち大きい方
  • M: 目標値付近
  • H: PB と PS のうち大きい方

誤差変化の評価はそのまま3段階で評価 * N: 目標値に近づいている * Z: 変化なし * P: 目標値から遠ざかっている

出力は5段階にまとめます。

  • NB: すごく下げる
  • NS: ちょっと下げる
  • Z: 変えない
  • PS: ちょっと上げる
  • PB: すごく上げる

ここで決めた速度誤差と誤差変化の組み合わせから、出力を決定するルールを決めます。

今回は以下のように決めました。

L M H
N PB PS NS
Z PB Z NB
P PS NS NB

表の縦方向は誤差変化(N/Z/P)、横方向は速度誤差(L/M/H)を表しています。

いくつか抜き出すと以下のようなルールになります。

  • 遅くて誤差が減ってないなら、出力を上げる
  • 誤差が少なく、誤差も変化ないなら、そのまま
  • 速くて誤差が変わらないなら、ちょっと減らす

このルールによって決めたルールをもとに出力を決定します。

実装例

以下に実装例を示します。
すべてを理解する必要はなく、「ルールがどのようにコードに落とされているか」の参考として載せています。

---@class FuzzySpeed:BaseClass
---@section FuzzySpeed _FuzzySpeed_

FuzzySpeed = {
  ---@param v_range number 誤差レンジ [kn] (default 15)
  ---@param dv_range  number 誤差変化レンジ [kn] (default v_range/2)
  ---@param dv_lp number 誤差変化ローパス係数 (0..1, default 0.3)
  ---@param k_speed number RPS目標更新ゲイン (default 0.03)
  ---@param rps_min number RPS下限 (default 3.0)
  ---@param rps_max number RPS上限 (default 10.0)
  ---@return FuzzySpeed
  new = function(self, v_range, dv_range, dv_lp, k_speed, rps_min, rps_max)
    return LifeBoatAPI.lb_copy(self, {
      V_RANGE   = v_range,
      DV_RANGE  = dv_range,
      DV_LP     = dv_lp,
      K_SPEED   = k_speed,
      RPS_MIN   = rps_min,
      RPS_MAX   = rps_max,

      prev_v_error = 0.0,
      v_de_lp      = 0.0,
    })
  end,

  reset = function(self)
    self.prev_v_error = 0.0
    self.v_de_lp      = 0.0
  end,

  _clamp = function(x, a, b)
    if x < a then return a end
    if x > b then return b end
    return x
  end,

  _tri = function(x, a, b, c)
    if x <= a or x >= c then return 0.0 end
    if x == b then return 1.0 end
    if x < b then
      return (x - a) / (b - a)
    else
      return (c - x) / (c - b)
    end
  end,

  _trap = function(x, a, b, c, d)
    if x <= a or x >= d then return 0.0 end
    if x >= b and x <= c then return 1.0 end
    if x > a and x < b then
      return (x - a) / (b - a)
    else
      return (d - x) / (d - c)
    end
  end,

  _wmin = function(a, b)
    if a < b then return a else return b end
  end,

  calcUSpeed = function(self, speed, t_speed)
    local clamp = self._clamp
    local tri   = self._tri
    local trap  = self._trap
    local wmin  = self._wmin

    local v_error = speed - t_speed
    local dv_raw = v_error - self.prev_v_error
    self.prev_v_error = v_error
    local v  = clamp(v_error, -self.V_RANGE,  self.V_RANGE)
    local dv = clamp(dv_raw,  -self.DV_RANGE, self.DV_RANGE)

    self.v_de_lp = self.v_de_lp + self.DV_LP * (dv - self.v_de_lp)
    dv = self.v_de_lp

    local vN  = (v  / self.V_RANGE)  * 2.0
    local dvN = (dv / self.DV_RANGE) * 2.0

    -- 速度誤差のメンバーシップ
    local mu_v_NB = tri (vN, -4.0, -2.0, -1.0)
    local mu_v_NS = tri (vN, -2.0, -1.0,  0.0)
    local mu_v_Z  = trap(vN, -0.6, -0.15, 0.15, 0.6)
    local mu_v_PS = tri (vN,  0.0,  1.0,  2.0)
    local mu_v_PB = tri (vN,  1.0,  2.0,  4.0)

    -- 誤差変化のメンバーシップ
    local mu_dv_N = tri (dvN, -4.0, -1.0,  0.0)
    local mu_dv_Z = trap(dvN, -0.5, -0.05, 0.05, 0.5)
    local mu_dv_P = tri (dvN,  0.0,  1.0,  4.0)

    -- L/M/H 集約
    local mu_v_L = mu_v_NB
    if mu_v_NS > mu_v_L then mu_v_L = mu_v_NS end

    local mu_v_M = mu_v_Z

    local mu_v_H = mu_v_PB
    if mu_v_PS > mu_v_H then mu_v_H = mu_v_PS end

    -- ルール発火度
    local w = {}

    -- L(遅い → 上げたい)
    w[1] = wmin(mu_v_L, mu_dv_N)  -- PB
    w[2] = wmin(mu_v_L, mu_dv_Z)  -- PB
    w[3] = wmin(mu_v_L, mu_dv_P)  -- PS

    -- M(付近)
    w[4] = wmin(mu_v_M, mu_dv_N)  -- PS
    w[5] = wmin(mu_v_M, mu_dv_Z)  -- Z
    w[6] = wmin(mu_v_M, mu_dv_P)  -- NS

    -- H(速い → 下げたい)
    w[7] = wmin(mu_v_H, mu_dv_N)  -- NS
    w[8] = wmin(mu_v_H, mu_dv_Z)  -- NB
    w[9] = wmin(mu_v_H, mu_dv_P)  -- NB

    -- 出力レベル調整
    OUT_NB = -0.8
    OUT_NS = -0.4
    OUT_Z = 0.0
    OUT_PS = 0.4
    OUT_PB = 0.8

    local z = {}
    z[1] = OUT_PB
    z[2] = OUT_PB
    z[3] = OUT_PS
    z[4] = OUT_PS
    z[5] = OUT_Z
    z[6] = OUT_NS
    z[7] = OUT_NS
    z[8] = OUT_NB
    z[9] = OUT_NB

    -- 重み付き平均
    local num, den = 0.0, 0.0
    for i = 1, 9 do
        num = num + w[i] * z[i]
        den = den + w[i]
    end

    local u_speed = 0.0
    if den > 1e-6 then
        u_speed = num / den
    end

    -- 誤差スケーリング
    local mag = math.abs(v_error) / self.V_RANGE
    if mag > 1.0 then mag = 1.0 end

    u_speed = u_speed * mag

    return u_speed
  end,

  update = function(self, speed, t_speed, throttleRPS)
    local clamp   = self._clamp
    local u_speed = self:calcUSpeed(speed, t_speed)

    throttleRPS = clamp(
      throttleRPS + self.K_SPEED * u_speed,
      self.RPS_MIN, self.RPS_MAX
    )

    return throttleRPS, u_speed
  end,
};

---@endsection _FuzzySpeed_

パラメータ調整

速度 0[knot] から 10[knot] に指定し、速度を維持するのを例にパラメータを調整をします。
(グラフは雑に測定したので全体の傾向のみ参考にしてください)

K_SPEEDを変える

K_SPEED(ゲイン)を調整します。 K_SPEED は「ファジー制御の判断を、どれくらいの速さで RPS 指令に反映するか」を決めるパラメータです。 ファジー制御の出力が、次の制御量を -1~1 程度で出力してきますが、そのまま投入すると変化が激しくなってしまいます。 そのため、少しづつ更新するために1回あたりの変化量を調整するのがこの K_SPEED になります。

以下に K_SPEED を変化させたときの速度を以下に示します。 縦軸は速度[knot] 横軸は時間[tick] です。

K_SPEED を変化させたときの速度

この系において 0.005 の時、振動はするが目標値に向かって収束していることがわかります。 0.01など、パラメータを大きくし素早く目標値が変わる場合は振動していることがわかります。 これは、内側のループであるエンジンの出力である RPS が目標値に対して素早く追従しないため、目標値に達してもすぐには変化しないため逆に振動してしまっているのではないかと考えられます。

K_SPEED = 0.01 の時の速度変化

振動を少なくしたい

K_SPEED = 0.005 の際に安定することが分かったので、さらに振動を少なくすることを考えます。 ここでは誤差スケーリングを導入します。 誤差スケーリングは、目標からの誤差が大きいときは強く制御し、誤差が少ないときは制御を弱める仕組みです。 これにより、誤差が少ない場合(目標値に近い場合) は柔らかく制御するようになります。

    local mag = math.abs(v_error) / self.V_RANGE
    if mag > 1.0 then mag = 1.0 end

    u_speed = u_speed * mag

以下に 誤差スケーリングありなしでの変化を以下に示します。 縦軸は速度[knot] 横軸は時間[tick] です。

誤差スケーリングありなしでの速度変化
誤差スケーリングを有効にした場合、初回はオーバーシュートはしますが無効の時と比較して振動が明らかに減っていることがわかります。

まとめ

今回は Stormworks 上の小型船を対象に、一定速度を保つためにファジー制御を実装しその挙動を確認しました。

ファジー制御によって以下の点がわかりました。

  • 人の感覚によって定義したルールに従って制御できるため直観的に設計ができる。
  • PID と同様にある程度の調整は必要
  • PID 制御との組み合わせをすることは可能

制御対象の挙動を完全にモデル化し辛い環境では、ファジー制御のような「少し強めに」「ちょっと戻したい」といった人間的な感覚を取り入れることが容易になる点はファジー制御の大きなメリットだと思います。 一方で、追従性については速度型PIDなどを採用した方が振動も少なく素早く制御できるという場合もあります。 そのため制御対象によってファジー制御にするかPIDにするかは、どの程度誤差が許容されるかや高い追従性が求められるかなどによって検討する必要があります。

ファジー制御に興味を持った方の参考になれば幸いです。

その他

ログの取得には以下の「コンポジット信号をグラフ化するやつ」を利用しています。 stormskey.works 手軽にログとれるので助かりました。ありがとうございます。

制御パラメーターの調整を自動化しようの会

この記事は Stormworks 第1 Advent Calendar 2024 15 日目の記事です。

はじめに

Stormworks は vehicle の制御、船舶でいえばエンジンの制御、速度の制御、進行方向の制御など、ほぼすべての操作をプレイヤーの手で行う必要があります。また、この制御にかかわる機構の作成をプレイヤーが行う必要があります。 すべてをスロットルレバーやボタン等で行うこともできますがかなり負担が高いです。 この負担を減らすために、マイコン等のロジックを用い自動化します。

この記事では、制限付I制御を用いた制御においてのパラメーター調整をある程度自動化した時の記録になります。

PID 制御に用いるパラメーター調整

制御対象を目標値に向けて制御するとき、とりあえず PID をつけることで、あまり複雑なことを考えずともそれっぽく制御できます。 PID はその名の通り 比例ゲイン(P ゲイン)、積分ゲイン(I ゲイン)、微分ゲイン(D ゲイン)の3つのパラメーター調整を適切に調整する必要があります。 調整の方法には 限界感度法や職人の経験や勘に頼った調整があります。 (筆者はこの時は大体この値にしとけばとりあえず安定するやろ的な雑な経験や勘に頼った調整をよくします。)

これらのパラメーターの調整は、一度システムを定常状態に置いたのち、ある目標値に対して制御した時の応答を評価することを繰り返すことで調整されます。 基本となる P ゲインを設定したのち、D ゲインの調整で振動を抑制し、最後に I ゲインを調整して定常偏差を減らすというのを繰り返して最終的なパラメーターを決定するのが基本的な調整法で用いられます。

ここでは、制限付I制御 を用い、実質的に調整するパラメーターを1つにすることで調整項目を減らし、調整の手間を減らします。 さらにこのパラメーター調整をある程度自動化することで、調整の手間を軽減させることを目指します。 制限付I制御について動画を参照してください。 【Stormworks】積分飽和のないI制御器とその使用法【ゆっくりけんきうしつ 第3部】

制限付I制御におけるパラメーター調整

制限付I制御では、Iゲイン、先読みゲインの2つのパラメーターが調整対象になります。 大まかな調整手順は以下のようになります。

  1. 先読みゲインを0にし、Iゲインを調整し、出力上限、下限にぶつからない程度の値を探す。
    1. で見つけた I ゲインを設定し、あるステップ目標値に対する応答を見つつ、先読みゲインを調整する

I ゲインに関してはステップ目標値に対しての応答を見ずとも、目標値付近での振動しているときの振幅を見ることで調整できます。 I ゲインはある程度あっていれば良いので、おおざっぱに手動調整で決定します。(先読みゲインの調整に比べれば簡単)

先読みゲインに関してはステップ目標値に対しての応答を見る必要があります。 エンジン制御でいえば、一度アイドル状態で安定させた状態で先読みゲインを設定し、その後目標値を設定してその応答を見ることが必要になります。 オーバーシュートすれば先読みゲインを増やし、逆に目標値までに到達する時間が長ければ減らすなどの調整を繰り返します。

この先読みゲインの調整をある程度自動化することで省力化します。

調整の自動化

最適な先読みゲインを、目標値に素早く追従し、できるだけ振動をしていないときの値とします。 今回はどの程度最適な値なのかを、標準偏差が小さいことと、目標値と制御値の平均値の2乗が小さいこと、と仮定しこれを評価関数とします。 (今回は雑に評価関数を設定していますが、この評価関数が適切でない場合最適値を求めることはできません。) この評価関数が最も小さい値を求めることで、最適な先読みゲインを得ることができます。

これらが最小になる値を雑に求めるために、今回は最急降下法を使って求めます。 最急降下法はある関数の最小値を求めるアルゴリズムの1つです。 詳細は省きますが、雑にいうとある地点からでの接線の傾きが小さい方向に少しづつ移動することを繰り返し、接線の傾きが0になる地点を求めるものです。 今回は接線の傾きに相当する値を、ある基準値からごく小さい値ずらした2点の差分をとることで疑似的に求めます。

ある先読みゲインを設定した際の、ごく小さい値ずらした2点の挙動を計測し、それぞれの評価関数の差が小さくなるように繰り返して計測することで、おおよそ最適値と呼べる値を得ようという作戦です。

実験する

今回はジェットエンジンのパラメーターを調整してみます。 RPS を 制限付I制御によって制御しており、この時の先読みゲインを調整します。 Iゲインは 0.005 目標 RPSは 20 とし、変化量が 0.0001 以下になったら完了としています。

システム全体像

以下の手順で調整するようにマイコンを作成しました。

  1. 起動
  2. 定常状態になるまで待機
  3. 調整値 + d を先読みゲインとし、計測
  4. 定常状態になるまで待機
  5. 設定値 - d を先読みゲインとし、計測
    1. および 5. の結果の差分をとり、次の設定値を計算する
  6. 変化量が小さくなるまで 2. から 6. を繰り返す。

調整中の様子

下画面に調整時のパラメータを表示している。 赤いラインが設定したRPS、黄色いラインが記録したRPSを示している
開始直後

終了時

若干オーバーシュートしているが、開始直後よりも素早く定常状態に落ち着いている

まとめ

今回は制限付I制御のパラメーターをある程度自動化するシステムを構築しました。 すべてのシステムにそのまま適用できるわけではないですが、作製したモジュラーエンジンや負荷状況によって最適なパラメーターの調整をある程度省力化できるようになります。 また、初期設定だけすれば放置するだけで調整できるため、調整中にほかの作業も行えます。(ストワ内での作業はできませんが。。)

今後の展望として、複数のRPSに対しても調整することで、RPSごとに最適な先読みゲインを自動で調整することができるのではないかと考えます。