前回 はじめてなElixir(9) は GenStateMachine の例に終止してしまったので、もすこし複雑な状態マシンを作ります。なお、はじめてなElixir(7) でFSMについてぼやいたのに対して @niku さんがコメントをしてくれてます。合わせてご覧ください。
GenStateMachine を使ってやや複雑な FSM を表現してみる
今回は、小水力発電所の状態遷移を例に簡単なElixirプログラムを書いてみましょう。ニッチやなぁ。
plantuml で状態遷移図を描いてみる
一旦、脇道にそれますが、UMLという表記法でモデルを表現することができます。お絵かきツールはいろいろあるみたいですが、私はたまたま PlantUML というツールを使ってます。
詳しくは中を見ていただくとしてこんな図が描けます。
これは GenStateMachine の例を図式化したものです。もとのテキストはこれです。
@startuml
off: dark
on: bright
off --> on: flip
on --> off: flip
off --> off: get_count
on --> on: get_count
@enduml
と機嫌よく図ができるのですが、微妙な使いにくさを感じててこのところあまり使ってません。
最も簡単な小水力発電所で有限状態マシンを考える
運転と停止だけあるような発電所を考えてみましょう。
これは私のやってる小水力発電所の状態遷移図(をごくごく簡略化したもの)です。
単にオンで運転中になるのではないところが普通の電化製品と違うところです。起動ボタンをオンにするとおおまかに以下の事象が起こります。
- 各種状態のチェックを行い正常性の確認をする
- ブレーカ(これがやたらと沢山ある)が落ちてないか
- 発電機の温度は正常か
- 発電用の水は十分にあるか(取水口のタンクの水位で判断します)
- 入力弁を開けて水を通す
- 水車に水が通って発電機を回し始める
- 発電機が回転して電力を発生して電力系統側に送り出す
なのでオペレータが起動をかけても直ちには運転中にならないので、そのプロセスが状態遷移に反映されてます。
このシーケンス図は以下の記述からツールで図に変換してます。
@startuml
Standby: 起動準備
Start: 起動中
Run: 運転中
[*] --> Standby : 電源ONによる初期化プロセス
Standby --> Start : 運転スイッチON
Standby: 起動準備
Start: 起動中
Run: 運転中
@enduml
これを Elixir で書いてみますね。
defmodule Shp do
use GenStateMachine
def handle_event(:cast, :boot_up, :begin, count) do
{:next_state, :standby, count}
end
def handle_event(:cast, :turn_on, :standby, count) do
{:next_state, :start, count}
end
def handle_event(:cast, :spin_up, :start, count) do
{:next_state, :run, count + 1}
end
def handle_event(:cast, :turn_off, :run, count) do
{:next_state, :standby, count}
end
def handle_event(:cast, :shutdown, :standby, count) do
{:next_state, :end, count}
end
def handle_event({:call, from}, :get_count, state, count) do
# IO.inspect "state=#{state}, count=#{count}"
{:next_state, state, count, [{:reply, from, {state, count}}]}
end
end
では小水力発電所を動かしてみます。ここでは pid を持ち回らず発電所に名前をつけてみましょう。
iex(1)> {:ok, _} = GenStateMachine.start_link(Shp, {:begin, 0}, name: KUTE)
{:ok, #PID<0.169.0>}
iex(2)> GenStateMachine.call(KUTE, :get_count)
{:begin, 0}
iex(3)> GenStateMachine.cast(KUTE, :boot_up)
:ok
iex(4)> GenStateMachine.cast(KUTE, :turn_on)
:ok
iex(5)> GenStateMachine.cast(KUTE, :spin_up)
:ok
iex(6)> GenStateMachine.cast(KUTE, :turn_off)
:ok
iex(7)> GenStateMachine.call(KUTE, :get_count)
{:standby, 1}
iex(8)> GenStateMachine.cast(KUTE, :turn_on)
:ok
iex(9)> GenStateMachine.cast(KUTE, :spin_up)
:ok
iex(10)> GenStateMachine.cast(KUTE, :turn_off)
:ok
iex(11)> GenStateMachine.call(KUTE, :get_count)
{:standby, 2}
iex(12)> GenStateMachine.cast(KUTE, :shutdown)
:ok
久手発電所に灯が入り、2回運転して、終了しました。
もう少し複雑な小水力発電所
正常な状態だけで運転できるとは限りませんので、故障状態も作ってみます。
これは
- 異常を検出して故障あつかいで停止する
- 異常の原因を取り除けたらリセットボタンで復旧させる
という状態遷移を追加してます。例えば
- 発電機の温度がやたらと上昇した
- 油圧ポンプ用の油面が低下した(一部の機械操作に油圧を使うことがあります)
なんてことが運転中に起こると、一旦発電を停止して人間の目で異常を確認して原因を取り除かないとなりません。で、取り除けたなと判断したら、リセットボタンを押して運転が再度可能な状態にします。
シーケンス図は以下から生成してます。
@startuml
Standby: 起動準備
Start: 起動中
Run: 運転中
Suspend: 故障停止
[*] --> Standby : 電源ONによる初期化プロセス
Standby --> Start : 運転スイッチON
Start --> Run : 起動OK
Run --> Standby : 運転スイッチOFF
Run --> Suspend : 異常検出
Suspend --> Standby : リセット
Standby -> [*] : PLC 電源 OFF
@enduml
これに沿ってElixirを修正してみます。状態遷移が2つ加わります。先程の elixir プログラムに以下を追加します。(初稿のときに間違ったイベントを持ってきてたので修正)
def handle_event(:cast, :fault, :run, count) do
{:next_state, :suspend, count}
end
def handle_event(:cast, :recover, :suspend, count) do
{:next_state, :standby, count}
end
今度は異常を起こして回復させるように遷移させてみます。
iex(1)> {:ok, _} = GenStateMachine.start_link(Shp, {:begin, 0}, name: KUTE)
{:ok, #PID<0.181.0>}
iex(2)> GenStateMachine.cast(KUTE, :boot_up)
:ok
iex(3)> GenStateMachine.cast(KUTE, :turn_on)
:ok
iex(4)> GenStateMachine.cast(KUTE, :spin_up)
:ok
iex(5)> GenStateMachine.cast(KUTE, :fault)
:ok
iex(6)> GenStateMachine.cast(KUTE, :recover)
:ok
iex(7)> GenStateMachine.call(KUTE, :get_count)
{:standby, 1}
iex(8)> GenStateMachine.cast(KUTE, :shutdown)
:ok
タイムアウトを入れてみる
シーケンスで異常を取り扱うのは、センサで異常を検出する以外にも、状態遷移があるはずなのに(なにかアクションをかけたはずなのに)そっちに移らない… というのも異常と取り扱います。それはタイムアウトで検出します。
なお、ここでセンサと書いたのは、制御の世界ではざっくり「リレー」と呼ばれちゃいますが、要は何かの値・状態を計測・検出するデバイスということです。
これは運転スイッチをONにしてからしばらくしても運転が開始されないときに、異常と判断して故障扱いにする遷移が加わってます。例えば実際の例では、機械がなにか噛み込んでしまってて動かなくなってる場合とかがあります。なお、制御ではタイムアウトを「渋滞」と表現します。
このシーケンス図は以下の表記から生成してます。
@startuml
Standby: 起動準備
Start: 起動中
Run: 運転中
Suspend: 故障停止
[*] --> Standby : 電源ONによる初期化プロセス
Standby --> Start : 運転スイッチON
Start --> Run : 起動OK
Start --> Suspend : 起動渋滞
Run --> Standby : 運転スイッチOFF
Run --> Suspend : 異常検出
Suspend --> Standby : リセット
Standby -> [*] : PLC 電源 OFF
@enduml
起動渋滞を表現したElixirを書いてみます。まず :stanby から :turn_on を受け取って :start に遷移する行にタイムアウトの時間を第4引数に ms 値で設定します。
def handle_event(:cast, :turn_on, :standby, count) do
{:next_state, :start, count, 5000}
end
これで「:start の状態に遷移して 5000ms まで次のイベントを待つ」という意味になります。(そういう風になるような気がしてます)
さらにタイムアウト処理の以下の行を加えます。これは「:start 状態でタイムアウトが発生したら :suspend に遷移する」ことを示します。(示しているような気がします)
def handle_event(:timeout, event, :start, count) do
IO.inspect("!! Timeout #{event}ms, cannot change the state to :run")
{:next_state, :suspend, count}
end
これを実行してみます。何となく希望している通りの動作をしているようです。
iex(1)> {:ok, _} = GenStateMachine.start_link(Shp, {:begin, 0}, name: KUTE)
{:ok, #PID<0.169.0>}
iex(2)> GenStateMachine.cast(KUTE, :boot_up)
:ok
iex(3)> GenStateMachine.cast(KUTE, :turn_on) # 次が5秒以内にくると…
:ok
iex(4)> GenStateMachine.cast(KUTE, :spin_up)
:ok
iex(5)> GenStateMachine.call(KUTE, :get_status) # 正常運転
{:run, 1}
iex(6)> GenStateMachine.cast(KUTE, :turn_off)
:ok
iex(7)> GenStateMachine.cast(KUTE, :turn_on) # 次が5秒立っても来ず…
:ok
"!! Timeout 5000ms, cannot change the state to :run"
iex(8)> GenStateMachine.call(KUTE, :get_status) # 故障停止
{:suspend, 1}
iex(9)> GenStateMachine.cast(KUTE, :recover)
:ok
iex(10)> GenStateMachine.call(KUTE, :get_status)
{:standby, 1}
iex(11)> GenStateMachine.cast(KUTE, :shutdown)
:ok
iex(12)> GenStateMachine.call(KUTE, :get_status)
{:end, 1}
バラバラと修正したので、改めて Elixir プログラム全体を見てみます。
defmodule Shp do
use GenStateMachine
def init(_args) do
{:ok, :begin, 0}
end
def handle_event(:cast, :boot_up, :begin, count) do
{:next_state, :standby, count}
end
def handle_event(:cast, :turn_on, :standby, count) do
{:next_state, :start, count, 5000}
end
def handle_event(:timeout, event, :start, count) do
IO.inspect("!! Timeout #{event}ms, cannot change the state to :run")
{:next_state, :suspend, count}
end
def handle_event(:cast, :spin_up, :start, count) do
{:next_state, :run, count + 1}
end
def handle_event(:cast, :turn_off, :run, count) do
{:next_state, :standby, count}
end
def handle_event(:cast, :fault, :run, count) do
{:next_state, :suspend, count}
end
def handle_event(:cast, :recover, :suspend, count) do
{:next_state, :standby, count}
end
def handle_event(:cast, :shutdown, :standby, count) do
{:next_state, :end, count}
end
def handle_event({:call, from}, :get_status, state, count) do
{:next_state, state, count, [{:reply, from, {state, count}}]}
end
end
やりたいことは何となくできるようになりました。が、:state_timeout ってのもあって気になってます。本当はこっちのほうが適してるのかも。時間があったらもうちょっと攻めてみることにします。
参考文献
なんとなくなDeveloperのメモ Elixir でステートマシンを処理
Elixir 1.7.3 (english)
Elixir(日本語版)
GenStateMachine (elixir)
↑だけだと舌足らずでよく意味が分からない。こっちも読んだほうがよさそう↓
gen_statem (erlang)