最近Pythonの状態遷移パッケージ(transitions)の記事を書いてたら「これ状態遷移表作れるのでは?」と思って実際にやってみたら自動生成できたので紹介します。
まえがき
状態遷移図/状態遷移表とかtransitionsパッケージについてとか知らない人のためにサクッと紹介。
「そんなの知ってるよ」とか「早く本題を!」という方は本題まで読み飛ばしてもらってOKです。
状態遷移とは
そもそも状態遷移は「とある状態であるイベントが起こったら、別の状態や自己の状態に遷移(状態を変える)」というもので、有限ステートマシンという言葉でも表されたりします。
状態遷移図はそれを図示したもので、以下のような形になります。例えばスイッチのON/OFFで電源入り状態と電源切り状態を示すには以下のような形です。
下記図において、電源OFFや電源ONというのは「状態」という円状のマークで示されていて、矢印が遷移の方向になり、矢印の上に書かれているモノが「イベント」と言います。
イベントにおいては、遷移を引き起こす為のイベントをトリガーイベントと言ったりします。したがって状態「電源OFF」におけるスイッチONイベントはトリガーイベントですが、スイッチOFFイベントはトリガーイベントとはなりません。
状態遷移の設計と状態遷移表
さてさて、状態遷移は通常ソフトウェア設計のうち動的設計の一部で組込みソフトウェアではよく使われる手法ですが、通常はUMLのステートマシン図などで記載して動作を設計します。
最近であればPlantUMLがテキストベースで書けて、さらにテキストベースなのでGit上で管理出来るので便利です。
例えば冒頭の状態遷移図をPlantUMLで書いてみると以下のような形になります。
@startuml
state "電源OFF" as State_OFF
state "電源ON" as State_ON
State_OFF-> State_ON: スイッチON
State_ON-> State_OFF: スイッチOFF
@enduml
上記コードをPlantUMLの公式サイトやAtomもしくはVS codeのPlantUMLプラグインで表示すると以下のような図を得られます。
これで状態遷移図ができました。
状態遷移の設計は通常これで終わりではなく、次いで状態遷移表を作成します。
状態遷移表の記法は様々な文化がありますが、今回は表の列項目に状態を、行項目にイベントを記載していきます。
電源OFF | 電源ON | |
---|---|---|
スイッチOFF | ―― | 電源OFF |
スイッチON | 電源ON | ―― |
状態遷移表を作る理由は「特定の状態でトリガーイベント以外のイベントが起こったらどうなるか」といった抜け漏れを防ぐことがに挙げられます。
ソフトウェアの実装にもよりますが、通常トリガーイベント以外のイベントが各状態に起こらないとは言えません。
この例のように2状態2遷移だけであれば大丈夫だとは思いますが、状態や遷移が複数になり複雑になると状態遷移図だけでは抜け漏れる可能性が高くなるので、状態遷移図と状態遷移表の作成はセットで考えておくと良いでしょう。
なお、上記状態遷移表の例でトリガーイベント以外のイベントが起こった際は「――」で示しました。ソフトウェアで実装する場合はこれらの扱いは実装に左右されますが、そのイベントが起きたら「無視」するか「禁止」にするかの二択が多いです。前者の「無視」はイベントが起きても遷移や遷移に伴うアクションは起こさないという意味合いで使われることが多いです。後者の「禁止」はそもそもそのイベントが発生すること自体を許していない事が多く、実装上は例外にすることが多いとは思います。(処理上無視としてしまう場合もあります)
transitionsパッケージ
まえがきその2。ここからは話が変わってtransitionsパッケージについてです。
transitionsパッケージはPythonで状態遷移を実現するライブラリになります。出来ることは多岐にわたっており、状態遷移の定義から状態遷移図を作ることができます。
一応簡単な導入方法と実装例を紹介します。
インストールは以下のようにpipで行えます。
pip install transitions
次いでtransitionsパッケージを用いた状態遷移の定義になります。
from transitions import Machine
states = ['OFF', 'ON'] #状態の定義
transitions = [
{'trigger':'switch_ON', 'source':'OFF', 'dest':'ON'}, # 状態OFFからONへの遷移定義
{'trigger':'switch_OFF', 'source':'ON', 'dest':'OFF'}, # 状態ONからOFFへの遷移定義
]
class Model(object):
pass
model = Model()
machine = Machine(model=model, states=states, transitions=transitions, initial=states[0],
auto_transitions=False, ordered_transitions=False)
上記ではまだ状態が定義されただけですが、transitionsパッケージは実際にステートマシンとして状態を変えていくことが出来ます。
以下は上記実装例において、switch_ONイベントを起こし、状態OFFから状態ONに遷移する動作例になります。
model.state # 初期状態の確認
'OFF'
model.switch_ON() # イベントの起こし方
True
model.state # イベント後の状態確認
'ON'
その他、遷移時のアクションとしてコールバックなども行えますが本題ではないので割愛します。詳細は下名が作成した関連する記事をご覧いただけたらと思います。
補足的な話ですが、transitionsパッケージではGraphVizを用いてグラフ出力することが出来ます。Linuxであれば以下のパッケージをインストールすれば動作可能です。
WindowsやmacOS上へのインストールなどについてやJupyterNotebook上での表示など、便利な使い方についての詳細はPythonの状態遷移パッケージ(transitions)を理解する【準備編】に譲りますが簡単に紹介しておきます。
sudo apt-get install graphviz graphviz-dev
pip install pygraphviz
上記をインストールした上で以下のコードを実行すると、「transitionsパッケージの実装例」で示した動作する状態遷移をグラフ出力できます。
from transitions.extensions import GraphMachine
states = ['OFF', 'ON', ] #状態の定義
transitions = [
{'trigger':'switch_ON', 'source':'OFF', 'dest':'ON'},
{'trigger':'switch_OFF', 'source':'ON', 'dest':'OFF'},
]
class Model(object):
pass
model = Model()
machine = GraphMachine(model=model, states=states, transitions=transitions, initial=states[0],
auto_transitions=False, ordered_transitions=False,
title='', show_auto_transitions=True, show_conditions=True)
model.get_graph().draw('transitions.png', prog='dot', format='png') #ファイルで出力
上記を実行するとこんな感じでグラフが画像出力されたと思います。
以下本題でも度々状態遷移図が出てきますが、それはこのGraphMachineで定義し直したコードを画像出力したものになります。
transitions_tableによる状態遷移表の生成
さて、ここからが本題になります。
前述の通り、Pythonのtransitionsパッケージはグラフ化できたりと非常に有用なのですが、状態遷移表が作れないのが歯がゆいところでした。
そこでtransitionsパッケージを用いて作成した状態遷移より自動的に状態遷移表を作るtransitions_tableを実装してみました。
transitions_tableのインストール
本体はGitHubにあるので、以下のコマンドでローカルの適当なフォルダに落として来てもらえればと思います。
$ git clone https://github.com/nocatech/transitions_table.git .
次に、パッケージ化してあるので以下のPython installよりPython環境下にインストールして使えます。
$ python setup.py install
この際、transitionsパッケージとpandasが入ってないと一緒にインストールされるのでご注意を。
※Python 3.6, transitions 0.69, Windows10/64bitで動作確認済み(Pycharm/JupyterNotebook共に)
TransitionsTableの使い方
インストールし終わったらまず始めにimportでパッケージ内のTransitionsTableクラスを取り込んでもらえればと思います。
from transitions_table import TransitionsTable
このTransitionsTableクラスとtransitionsパッケージを使って状態遷移表を作ります。
当TransitionsTableは入力にtransitionsパッケージによる状態遷移を定義した際にできたmachineオブジェクト(MachineクラスもしくはGraphMachineクラスで作ったインスタンス)を引数に取ります。
そして、状態遷移表の生成はget_tableメソッドで行い、戻り値はpandasのDataFrameになります。
まずは冒頭でも出て行きましたが、電源ON/OFFについて実施してみます。
from transitions import Machine
states = ['OFF', 'ON'] #状態の定義
transitions = [
{'trigger':'switch_ON', 'source':'OFF', 'dest':'ON'}, # 状態OFFからONへの遷移定義
{'trigger':'switch_OFF', 'source':'ON', 'dest':'OFF'}, # 状態ONからOFFへの遷移定義
]
class Model(object):
pass
model = Model()
machine = Machine(model=model, states=states, transitions=transitions, initial=states[0],
auto_transitions=False, ordered_transitions=False)
ここでTransitionsTableを使って状態遷移表を作ってみます。
>>> transitions_table = TransitionsTable(machine)
>>> print(transitions_table.get_table())
source OFF ON
switch_OFF NaN OFF
switch_ON ON NaN
テキストベースでみにくいという方はJupyterNotebook上で実行してみることをオススメします。
from IPython.display import display
transitions_table = TransitionsTable(machine)
table = transitions_table .get_table()
display(table)
以下、JupyterNotebook上で表示してみた例です。きちんと状態遷移表になっていると思います。
まえがきで記載した電源ON/OFFに関する状態遷移表とも一致していると思います。
※Pycharm上でJupyterNotebookを動作させている場合は、pycharmのDataFrameがdisplayメソッドで表を描画できないというバグ(version 2018.3時点)により表示されないので、ブラウザベースのJupyterNotebookをご利用下さい。
念のため説明しますと、以下の通り表の列項目に遷移前状態(source)、行項目にイベント(event)、各表の内容が遷移後状態(dest)を示します。
デフォルトでは上記の通りですが、出力はDataFrameですので、もしも列項目にイベント(event)、行項目に遷移前状態(source)としたい場合はpandasの転置機能を使って変更も可能です(get_table().Tとする)。
TransitionsTableでの変換例
ここからはtransitionsパッケージで生成した状態遷移を状態遷移表への各種変換する例を示します。
各実施例の変換結果は見やすいJupyterNotebook上によるもので、状態遷移の定義以外は「transitionsTableのJupyterNotebook上における確認例」と同じコードを用いて状態遷移表の出力をしています。
もちろんTransitionsTableにおいてJupyterNotebookは必須ではありませんので環境に合わせて使ってもらえればと思います。
※簡単なテストはしていますが上手く動かない場合もあるかもしれません。一応下記例については動作確認済みの内容になります。
また、transitionsのコードだけでなくGraphMachineで変換し出力した状態遷移図についても併記しています。もちろんTransitionsTableはGraphMachineが無くても動作します。
①3状態3遷移の通常遷移のみの状態遷移
まずは3状態の簡単な通常遷移のみについて状態遷移表を出力してみます。
状態遷移の定義は以下の通り
from transitions import Machine
states = ['A', 'B', 'C']
transitions = [
{'trigger':'fromAtoB', 'source':'A', 'dest':'B'},
{'trigger':'fromBtoA', 'source':'B', 'dest':'A'},
{'trigger':'fromAtoC', 'source':'A', 'dest':'C'},
]
class Model(object):
pass
model = Model()
machine = Machine(model=model, states=states, transitions=transitions, initial=states[0],
auto_transitions=False, ordered_transitions=False)
こちらで作成したmachineオブジェクトをTransitionsTableによって状態遷移表にしてみると以下のようになります。
②全状態指定'*'を用いた複数遷移元からの遷移
次に遷移元(source)にのみ指定できる全状態指定'*'を用いた状態遷移に関してになります。
遷移元(source)に全状態指定'*'を指定すると遷移先(dest)も遷移元の対象となります。
from transitions import Machine
states = ['A', 'B', 'C', 'End']
transitions = [
{'trigger':'fromXtoEnd', 'source':'*', 'dest':'End'},
]
class Model(object):
pass
model = Model()
machine = Machine(model=model, states=states, transitions=transitions, initial=states[0],
auto_transitions=False, ordered_transitions=False)
上記定義を状態遷移図としてグラフ表示すると以下の通り。
こちらをTransitionsTableによって状態遷移表にしてみると以下のようになります。
③自己遷移と内部遷移
次は自己遷移と内部遷移について。
自己遷移は遷移先(dest)に遷移元と同じ状態もしくは'='を与えるとできます。内部遷移は遷移先(dest)にNoneを与えると可能です。
※両者の細かな違いについてはPythonの状態遷移パッケージ(transitions)を理解する【遷移編1】をご参照ください。
from transitions import Machine
states = ['A', 'B']
transitions = [
{'trigger':'self', 'source':'A', 'dest':'='},
{'trigger':'self', 'source':'B', 'dest':None},
]
class Model(object):
pass
model = Model()
machine = Machine(model=model, states=states, transitions=transitions, initial=states[0],
auto_transitions=False, ordered_transitions=False)
上記定義を状態遷移図としてグラフ表示すると以下の通り。
※内部遷移は遷移しているようにグラフ上はみえますが、実際に状態から出る/入るということは起こらないことにご注意ください。
こちらをTransitionsTableによって状態遷移表にしてみると以下のようになります。
ここで注意が必要なのは内部遷移についてです。状態遷移表上において内部遷移についてはデフォルトでは[internal]という文字で表示するようにしています。
④ユーザ定義外遷移(順序遷移)
次はユーザ定義外の遷移の一つである順序遷移について。
※ユーザ定義外遷移についてはPythonの状態遷移パッケージ(transitions)を理解する【遷移編2】をご参照ください。
from transitions import Machine
states = ['A', 'B']
transitions = [
{'trigger':'self', 'source':'A', 'dest':'='},
{'trigger':'self', 'source':'B', 'dest':None},
]
class Model(object):
pass
model = Model()
machine = Machine(model=model, states=states, transitions=transitions, initial=states[0],
auto_transitions=False, ordered_transitions=False)
上記定義を状態遷移図としてグラフ表示すると以下の通り。
こちらをTransitionsTableによって状態遷移表にしてみると以下のようになります。
きちんと状態Aの際にnext_stateイベントが発生すると状態Bに遷移し、状態Bの際は状態Cへ、状態Cの際は状態Aへ遷移することが表からも読み取れます。
⑤ユーザ定義外遷移(全状態遷移)
次はユーザ定義外の遷移の一つである全状態遷移について。
※ユーザ定義外遷移についてはPythonの状態遷移パッケージ(transitions)を理解する【遷移編2】をご参照ください。
from transitions import Machine
states = ['A', 'B', 'C']
class Model(object):
pass
model = Model()
machine = Machine(model=model, states=states, transitions=transitions, initial=states[0],
auto_transitions=True, ordered_transitions=False)
上記定義を状態遷移図としてグラフ表示すると以下の通り。
さてどうでしょうか、今までの例であれば状態遷移図を見た方が見通しが良かったと思いますが、ここまで遷移が多い上にイベント名が似通ってくると全遷移とイベントの抜け漏れをチェックするのが大変になってくるかと思います。
こちらをTransitionsTableによって状態遷移表にしてみると以下のようになります。
状態遷移表を見れば一目瞭然で、to_状態名というイベントが起こった際は現在の状態に関係無くその状態に遷移することがすぐ理解できると思います。
このような状態遷移設計の可否はともかく、この辺りから状態遷移表の効果が分かるかと思います。もちろん、遷移が設定されていなければデフォルトではNaNと表示されるので、抜け漏れても状態遷移図のみの場合に比べ早く気付くと思います。
TransitionsTableの細かな仕様について
さて、ここまで大まかな動作を確認できたと思いますが、machineオブジェクトを状態遷移表にする上で基本事項とそれ以外にいくつか注意事項と制約があります。
トリガーイベント以外(遷移のないイベント)の扱いと表示の変更について
実施例を見て頂いた方は気付いていると思いますが、各状態において状態遷移に寄与しない(トリガーイベントでない)イベントが発生した場合、生成される状態遷移表上では欠陥値NaNと表示されます。
これは大元のpandasの設定がそのようになっていたのでそのまま踏襲していますが、undefined_id引数で変更することが可能です。
transitions_table = TransitionsTable(machine, undefined_id='禁止')
table = transitions_table .get_table()
たとえば、上記のコードおいて実行例「①3状態3遷移の通常遷移のみの状態遷移」で定義したmachineを入力し、JupyterNotebook上で実行すると以下の通りになります。
各状態においてトリガーイベント以外のイベントに対し、NaNが'禁止'に置き変わったのを確認できると思います。
なお、この遷移のないイベントを発生させると、デフォルトでは(Machineの設定にもよりますが)例外を発生するようになっています。
これら挙動についてはPythonの状態遷移パッケージ(transitions)を理解する【遷移編1】#無効イベント無視について等をご参照ください。
内部遷移の扱いと表示の変更について
実行例でも言及しましたが、内部遷移(destにNone設定した遷移定義)についてはデフォルトでは'[internal]'という文字を割り当てています。
これは自己遷移と内部遷移でtransitionsパッケージ上若干の違いがあるためです。一方でこの表記はinternal_id引数を用いて'[internal]'という文字列から変更することが可能です。
transitions_table = TransitionsTable(machine, internal_id='無視')
table = transitions_table .get_table()
たとえば、上記のコードおいて実行例「③自己遷移と内部遷移」で定義したmachineを入力し、JupyterNotebook上で実行すると以下の通りになります
状態Bに設定されていた内部遷移においては'[internal]'が'無視'に置き変わったのを確認できると思います。
もちろんundefined_idとの併用も可能です。
TransitionsTableの例外(Exception)について
TransitionsTableが上げる明示的な例外としては2点ありますが、こちらは前述の「遷移のないイベントの表示変更例」と「内部遷移の表示変更例」であったとおり、TransitionsTableの任意設定である引数に関するものです。
TransitionsTableが出力する状態遷移表の内容を変更させるundefined_id引数とinternal_id引数は、それら引数を設定した場合にmachineオブジェクトに設定されている状態名と被る可能性があります。
特にこれら引数は状態遷移表中において遷移後状態(dest)が記載されている部分を変更するため、ユーザが定義した各状態とこれら引数の内容とが被ると混乱が生じるため、machineに設定してある状態名とこれら引数の置き換え名称が被った場合、ValueError例外を発生するようにしています(machineオブジェクトで取り扱っている状態名が優先されます)
例えば以下のようにmachineオブジェクトに定義されている状態'A'という文字をundefined_idでも設定して状態遷移表を作成しようとしてみます。
from transitions import Machine
states = ['A', 'B']
transitions = [
{'trigger':'fromAtoB', 'source':'A', 'dest':'B'},
]
class Model(object):
pass
model = Model()
machine = Machine(model=model, states=states, transitions=transitions, initial=states[0],
auto_transitions=False, ordered_transitions=False)
transitions_table = TransitionsTable(machine, undefined_id='A') #undefined_idにstatesで既に設定してある文字列が指定されている
table = transitions_table.get_table()
上記を実行すると以下の例外で実行が中断されると思います。
ValueError Traceback (most recent call last)
~略~
ValueError: 引数internal_idに状態元/遷移先に含まれる状態名を指定することはできません
undefined_id引数やinternal_id引数を用いる場合は、machineが取り扱う状態名(状態設定だけでなく遷移設定も含む)と被らないようご注意下さい。
TransitionsTableの警告(Warning)について
TransitionsTable上では例外にはならないがユーザに知らせるための警告(Warning)を一部実装しています。
警告を挙げる理由としては、transitionsパッケージ上表記が許されるが、状態遷移設計をする上で見落としがちでバグを生む可能性があるものについては警告を出しています。
警告はデフォルトで有効になっていますが、get_tableメソッドのis_warning引数をFalseにすると無効にできます。
① 未定義状態への遷移に対する警告
こちらは「Machineの状態(state)として定義されていないが、遷移(transitions)に遷移先/遷移元として定義されている」場合に警告を出す内容となります。
要は状態定義されてないのに遷移元/遷移先に出てくる状態を警告するものです。
transitionsパッケージとしては上記定義でも状態遷移を作ってしまえるのですが、状態を定義していないと状態に入った際のコールバックを指定し忘れる恐れもあるので警告を出すようにしています。
以下、実際の例になります。
from transitions import Machine
states = ['A'] #状態は'A'しか定義されていない
transitions = [
{'trigger':'fromAtoB', 'source':'B', 'dest':'A'}, # 状態定義(states)に設定のない状態'B'が遷移元に指定
{'trigger':'fromAtoC', 'source':'A', 'dest':'C'}, # 状態定義(states)に設定のない状態'C'が遷移先に指定
]
class Model(object):
pass
model = Model()
machine = Machine(model=model, states=states, transitions=transitions, initial=states[0],
auto_transitions=False, ordered_transitions=False)
実際に上記の状態遷移は作れてしまいます。以下の通りGraphMachineで状態遷移図もしっかり作成できます。
ただ実際に上記よりステートマシンを定義できても不定義状態への遷移に対するイベントをmodelオブジェクトに与えると例外が発生しますが、それ以前に気付く事ができると思います。
一方でこちらをTransitionsTableで状態遷移表を作成してみます。
transitions_table = TransitionsTable(machine)
table = transitions_table.get_table()
display(table)
実行はJupyterNotebook上ですが、スクリプト実行しても同様の警告は出ます。また警告が出るだけで、GraphMachineの状態遷移図と同様に定義された状態遷移を表にして出力しています。
当警告により、stateに状態BとCが定義されていない事に気付けると思います。
※ユーザ名/環境は黒塗りにしてあります。
② 定義済み状態の未定義遷移に対する警告
こちらは上記とは逆に「machineの状態(state)として定義されているのにもかかわらず、一つも遷移(transitions)が定義されていない」場合に警告を出す内容になります。
要は遷移の無い状態だけのものがあったら警告するものです。正直なところGraphMachineでグラフ描画すれば一発で分かるのですが、GraphMachineを使わない事も多くMachineのみで状態遷移を作る場合は役に立つと思います。
以下、実際の例になります。
from transitions import Machine
states = ['A', 'B', 'C'] #状態は3状態
transitions = [
{'trigger':'fromAtoB', 'source':'A', 'dest':'B'}, # 遷移設定は2状態に対してのみ
]
class Model(object):
pass
model = Model()
machine = Machine(model=model, states=states, transitions=transitions, initial=states[0],
auto_transitions=False, ordered_transitions=False)
こちらも同様に一部が状態のみの状態遷移は作れてしまいます。以下の通りGraphMachineで状態遷移図もしっかり作成できます。
こちらもTransitionsTableで状態遷移表を作成してみます。
transitions_table = TransitionsTable(machine)
table = transitions_table.get_table()
display(table)
きちんと警告がでて、どの状態が対象となっているのか分かると思います。
既知の問題
一部、状態遷移表に変換できない状態遷移があることを確認しています。
ただし以下の内容については発生した例外で止まった方が良いので紹介にとどめておきます。
① 重複した遷移定義について
こちらについてはTransitionsTableの仕様と言うよりpandas特性上の例外になってしまいます。
これは「同じ遷移元(source)から同じイベント名での遷移」が定義されていた場合に発生します。
通常遷移は遷移先(dest)に複数の状態を指定できなくなっていますが、それぞれ重複する遷移の内容を個別定義した場合は設定できてしまうようです。
from transitions import Machine
states = ['A', 'B', 'C']
transitions = [
{'trigger':'fromA', 'source':'A', 'dest':'B'}, # fromAイベントで遷移
{'trigger':'fromA', 'source':'A', 'dest':'C'} # 同じ遷移元で同じfromAイベントで遷移
]
class Model(object):
pass
model = Model()
machine = Machine(model=model, states=states, transitions=transitions, initial=states[0],
auto_transitions=False, ordered_transitions=False)
こちらもきちんとGraphMachineで状態遷移図を作成できてしまう上に動作します。
実際に当modelに対しfromA()メソッドでイベントを起こすと状態Bに遷移してしまいます(先に定義された方が優先されるようです)
多分transitionsパッケージのバグとは思いますが、transitions v0.69時点では再現できます。
こちらのmachineをTransitionsTableで状態遷移表にしようとしてみます。
>>> transitions_table = TransitionsTable(machine)
>>> table = transitions_table.get_table()
ValueError Traceback (most recent call last)
~略 アンド 略~
ValueError: Index contains duplicate entries, cannot reshape
結構長い例外が出た後、処理が中断されます。従って状態遷移表も作成されません。
この例外はpandasの変換処理を行う際に発生しているようです。
例外が分かりにくいので置き換えても良いのですが、少々複雑になりそうだったので一先ずは当事象の紹介のみにとどめておくことにしました。
注意点
当パッケージではtransitionsパッケージのHSM(Hierarchical State Machines : 階層型ステートマシン)には対応していないのでご注意下さい。
あとがき
長くなりましたが、transitionsパッケージを用いて状態遷移表を作れたのを確認頂けたかと思います。
正直改善点は色々あると思いますが、少しでもお役にたてれたら幸いです。
また、transitionsパッケージは割とマイナーなパッケージですがRaspberryPi等で制御する場合などで使えることも多く便利で、使い方の詳細記事を書いてますので気になった方はチュートリアルであるPythonで状態遷移(transitions)などをご覧頂けたらと思います。