Pythonで状態遷移(transitions)

Pythonには様々なパッケージがありますが、状態遷移を割と簡単に実装できる多機能なパッケージ「transitions」がありますのでそれを紹介したいと思います。
ソフトウェアで状態遷移を実装する場合、平に条件分岐などで書いていっても良いと思いますが、Pythonの場合は今回紹介する「transitions」が便利。

#状態遷移とは
システムにおいて、とある「状態」と「イベント」を定義し、それら各状態において各種イベントが起こった際、どういう状態の変化を行うか（一般的に状態が別の状態に移行することを遷移という）、イベントによってどのようなアクションを行うかといった事を記載する動的設計/実装手法です。
システムを「状態」と「イベント」、あとは「遷移」という形で整理することで、「この状態にあるとき、このイベントが起こった際はこの状態に遷移してこのアクションがおこる」という事が比較的容易に理解できシステムの動的な振る舞いを表現しやすいので、組込みシステムでは多く使用されています。（実際には状態遷移表と合わせて使われますがここでは割愛します）
参考：[状態遷移表設計手法の概要 (3/3) - MONOist - IT](http://monoist.atmarkit.co.jp/mn/articles/1204/06/news001_3.html)

#「transitions」について
「transitions」はPythonで状態遷移を実現するライブラリになります。
https://github.com/pytransitions/transitions

transitionsは状態遷移に関する色々な事ができますが代表的な事としては以下の通り。

* ステートマシンとしての振る舞いを組み込む
    * イベントが起きた際の状態管理
    * 移前後やイベントが起こった際の[アクション（コールバックメソッドの呼び出し）](https://qiita.com/noca/items/6a809bd1527b8e667da0#transitions%E3%81%AB%E3%81%8A%E3%81%91%E3%82%8B%E3%82%B3%E3%83%BC%E3%83%AB%E3%83%90%E3%83%83%E3%82%AF)
    * [ガード判定](https://qiita.com/noca/items/a132bb5e1ad4bf4eff6a#%E9%81%B7%E7%A7%BB%E6%99%82%E3%81%AE%E3%82%AC%E3%83%BC%E3%83%89%E5%88%A4%E5%AE%9A)の設定
* 階層的ステートマシンの実現（Hierarchical State Machine）
* 実装した状態遷移のグラフ化（状態遷移図として出力できる）
    * ※グラフ出力はちょっとした設定と条件があります。当記事ではグラフ化については割愛しますが詳細については[準備編](https://qiita.com/noca/items/53c01ac5afb687a98db8#%E3%82%B9%E3%83%86%E3%83%BC%E3%83%88%E3%83%9E%E3%82%B7%E3%83%B3%E3%81%AE%E8%A1%A8%E7%A4%BA%E3%81%AB%E3%81%A4%E3%81%84%E3%81%A6)をご確認下さい。

なおこの記事では基本となるtransitionsによる（状態/遷移等を管理する）ステートマシンの実装および使い方について述べます。
transitionsはpipで導入することができます（condaだと標準では入っていないかもしれません）。

```
pip install transitions
```

#サンプルコード
今回は上記全てではなく、まずは簡単な例から紹介します。
以下サンプルコードではMatterクラスからできたlumpオブジェクトがあり、そのlumpオブジェクトの状態を管理したいとします。
ここではまずlumpオブジェクトに対する「ステートマシンの実装」と「実装したステートマシンに対するイベントの起こし方」、「イベント発生時の状態遷移やアクションの実行」について確認してみます。

### ステートマシンの定義と実装
最初にステートマシンの定義と実装から。
以下は本家GitHub/transitionsのサンプルを若干いじったものになります。

```python
from transitions import Machine

#状態の定義
states = ['solid', 'liquid', 'gas', 'plasma']

#遷移の定義
# trigger：遷移の引き金になるイベント、source：トリガーイベントを受ける状態、dest：トリガーイベントを受けた後の状態
# before：遷移前に実施されるコールバック、after：遷移後に実施されるコールバック
transitions = [
    { 'trigger': 'melt',       'source': 'solid',   'dest': 'liquid'},
    { 'trigger': 'evaporate',  'source': 'liquid',  'dest': 'gas',     'before': 'action_l2g'},
    { 'trigger': 'sublimate',  'source': 'solid',   'dest': 'gas'},
    { 'trigger': 'ionize',     'source': 'gas',     'dest': 'plasma',  'after': 'action_g2p'}
]

#状態を管理したいオブジェクトの元となるクラス
# 遷移時やイベント発生時のアクションがある場合は、当クラスのmethodに記載する
class Matter(object):
    def action_l2g(self):
        print("*** from liquid to gas ***")
    
    def action_g2p(self):
        print("*** from gas to plasma ***")

lump = Matter()
machine = Machine(model=lump, states=states, transitions=transitions, initial='liquid', auto_transitions=False)
```

ダラダラと書いてありますが要はステートマシンの状態(states)と遷移（transitions）を定義し、Machineクラスを用いてlumpオブジェクトにステートマシンを設定しているだけです。
lumpオブジェクトはMatterクラスから出来ていますが、Machineクラスによりstatesやtransitions等で設定された状態/遷移を持つステートマシンが組み込まれた状態となっています（元のlumpオブジェクトに組み込まれる）。
上記で定義されlumpオブジェクトに組み込まれたステートマシンを図示すると以下のような状態遷移図となります（赤丸が現在の状態を表します）。
![liquid.png](https://qiita-image-store.s3.amazonaws.com/0/291448/f4752466-a24e-40db-20d4-263bb158a7fe.png)


### ステートマシンの実行
次にlumpオブジェクトに組み込まれたステートマシンに対し各種イベントを起こしたり、状態の確認などを行ってみます。
最初に現在の状態を確認してみましょう。現在の状態はstateプロパティを使うことで確認できます。
※ちなみに各種イベントの発生や状態の確認はMachineの戻り値であるmachineオブジェクトではなく、Machineクラスのmodel引数に指定したオブジェクト（ここではlump）が実態になるので、lumpに対し各種ステートマシンに関するメソッドやプロパティを実施します。

```python
>>> lump.state
'liquid'
```

Machineクラス呼び出し時に定義したinitial(=liquid)が初期状態となっている事が確認できると思います。
次に現在の状態に対しイベントを起こしてみます。イベントの起こし方はtrantisionsで定義した".イベント名()"となります。

```python
>>> lump.evaporate()
'*** from liquid to gas ***'
>>> lump.state
'gas'
```

上記はliquid状態においてevaporateイベントを起こしたことで、（transitionsで定義した通り）次の状態であるgas状態に遷移したことになります。その際、beforeに指定しておいたコールバック（action_l2g）が呼ばれて、action_l2gメソッド内のprint文が実行されています。
今回の遷移を図示すると以下のような状態遷移図になります（以前の状態が青丸で示され、遷移のトリガーとなったイベントは青矢印で示されています）。
現在の状態はgas状態であり、liquid状態から遷移してきたことが分かります。
![gas.png](https://qiita-image-store.s3.amazonaws.com/0/291448/dcd5afd2-4dee-3f34-075d-35011dba196e.png)

また、イベント名をメソッド的に指定しなくても以下の通りイベント名の文字列とtriggerメソッドを使ってトリガーイベントを起こさせることもできます。
次は現在の状態(gas状態)でionizeイベントを起こし、plasma状態に遷移してみましょう。

```python
>>> lump.trigger('ionize')
'*** from gas to plasma ***'
>>> lump.state
'plasma'
```
以上の状態遷移を図示すると以下の通りになります。
![plasma.png](https://qiita-image-store.s3.amazonaws.com/0/291448/63fb58cf-ffe1-7d3a-eb9b-85c41cab503e.png)

こちらも同様にionizeイベントにより遷移した際に、transitionsのafterに定義しておいたコールバック（action_g2p）が実施され、action_g2p内のprint文が実行されています。
なおtransitionsの定義において、遷移時のアクションとして'before'にコールバックを指定した場合では、コールバックが実施されるのは遷移「前」の状態(transitionsに定義したsource)で実施されます。一方で'after'にコールバックを指定した場合は遷移「後」の状態(transitionsに定義したdest上)で実施される点に注意。

その他、現在の状態において定義されていない遷移のイベントが発生すると通常MachineError例外が発生します。
例えばliquid状態においてionizeイベントを発生させた場合はMachineErrorが発生し、遷移が発生しない上にafterやbeforeのコールバックも実施されません。
※コールバックはafter/before以外にもあり、状態が遷移しなくても実施されるコールバック設定などもありますがここでは割愛します。


### クラスとして実装する
上記サンプルコードをクラスとして定義すると以下の通りになります。やってることは同じ。
遷移の定義は上記サンプルの通りtransitionsでまとめて定義してMachineクラス呼び出し時にtransitions引数に設定しても良いですし、下記のようにMachineでオブジェクトを作った後にadd_transitionで個別に追加していってもかまいません。

```python
class StateMachine(object):
    #状態の定義
    states = ['solid', 'liquid', 'gas', 'plasma']
    
    #初期化（ステートマシンの定義：とりうる状態の定義、初期状態の定義、各種遷移と紐付くアクションの定義）
    def __init__(self, name):
        self.name = name
        self.machine = Machine(model=self, states=StateMachine.states, initial='liquid', auto_transitions=False)
        self.machine.add_transition(trigger='melt',      source='solid',  dest='liquid')
        self.machine.add_transition(trigger='evaporate', source='liquid', dest='gas',    before='action_l2g')
        self.machine.add_transition(trigger='sublimate', source='solid',  dest='gas')
        self.machine.add_transition(trigger='ionize',    source='gas',    dest='plasma', after='action_g2p')
    
    #以下、遷移時のアクション
    def action_l2g(self):
        print("*** from liquid to gas ***")
    
    def action_g2p(self):
        print("*** from gas to plasma ***")
```

こちらも最初のサンプルコードの通り動作します。

```python
>>> lump = StateMachine('lump')
>>> lump.state
'liquid'
>>> lump.evaporate()
'*** from liquid to gas ***'
>>> lump.state
'gas'
>>> lump.trigger('ionize')
'*** from gas to plasma ***'
>>> lump.state
'plasma'
```

# 使いどころ
Pythonはデータ解析やら機械学習やらで使われることも多く、インタプリタ的に使う所では効果を発揮しないかも知れませんが、組込みlinuxなどPythonが動作するようなところでPythonを使った制御などを行う場面では有効かと思います。
特にRaspberryPiで何かを制御したり、イベントドリブンなシステムをPythonで実装する際には効果が出ると思います。またネットワークや画面遷移など状態に応じてイベントによって何かアクションを起こすところでは重宝すると思います。

# 関連記事
以下、transitionsパッケージに関する記事になります。状況によって下記以外の記事も作成するかもしれません。

|記事名|概要|
|:-----------------------------|:---------------------------|
|Pythonで状態遷移(transitions) [当記事]|transitionsパッケージのチュートリアル|
|[Pythonの状態遷移パッケージ(transitions)を理解する【準備編】](https://qiita.com/noca/items/53c01ac5afb687a98db8)|transitionsパッケージのインストール<br>およびグラフ出力を実現するための設定方法|
|[Pythonの状態遷移パッケージ(transitions)を理解する【状態編1】](https://qiita.com/noca/items/dcdd149c6189098afa39)|状態の定義や各種設定などの詳細|
|[Pythonの状態遷移パッケージ(transitions)を理解する【状態編2】](https://qiita.com/noca/items/e7cd12179d37b2cb4675)|状態へのタグ付け、終端状態例外の詳細|
|[Pythonの状態遷移パッケージ(transitions)を理解する【状態編3】](https://qiita.com/noca/items/5479ef8a9f681e4385c6)|状態毎のクラスインスタンス生成、<br>状態タイムアウト設定、独自状態の定義|
|[Pythonの状態遷移パッケージ(transitions)を理解する【遷移編1】](https://qiita.com/noca/items/a132bb5e1ad4bf4eff6a)|ユーザ定義遷移やコールバック、<br>ガード判定等に関する詳細|
|[Pythonの状態遷移パッケージ(transitions)を理解する【遷移編2】](https://qiita.com/noca/items/1d887ad21ef0c66a4c58)|ユーザ定義外遷移に関する詳細|
|[Pythonの状態遷移パッケージ(transitions)を理解する【コールバック編1】](https://qiita.com/noca/items/6a809bd1527b8e667da0)|コールバックの種類や実施順序に関する詳細|
|[Pythonの状態遷移パッケージ(transitions)を理解する【コールバック編2】](https://qiita.com/noca/items/2db1ac853ee285d04c0b)|コールバックにデータを与える方法、<br>キューについての詳細|
|[Pythonの状態遷移パッケージ(transitions)を理解する【HSM編1】](https://qiita.com/noca/items/6c0870d8fa63095d6b96)|階層型ステートマシンの基本動作の紹介|