pytransitions / transitions

A lightweight, object-oriented finite state machine implementation in Python with many extensions
MIT License
5.5k stars 525 forks source link

Nested machine not working as expected #332

Closed keivanzavari closed 4 years ago

keivanzavari commented 5 years ago

Hi I have been looking at closed issues, SO, and googling to solve this issue. But I haven't been able to solve my problem and this seems to be the only way to describe it and hope to get an answer. I am making a state machine which can include several substates which are also all state machines. So this basically boils down to reusing HSM according to readme.

my highest level SM looks like this:

from transitions.extensions import LockedHierarchicalMachine as Machine
from coordination.running import RunningStateMachine

logging.basicConfig(level=logging.ERROR)
logging.getLogger("transitions").setLevel(logging.INFO)

class RPPStateMachine(Machine):
    def __init__(self, name):
        self._running = RunningStateMachine()
        self.name = name
        states = [
            "init",
            {"name": "running", "children": self._running},
            "stop",
        ]

        Machine.__init__(self, states=states, initial="init")

        self.add_transition("e_run", "init", "run", after=self.run_machine)
        self.add_transition("e_stop", "*", "stop")

    def run_machine(self):
        self._running.initialize()

As you see a state machine with three states init, running and stop. Once the event e_run() is sent via something like

machine = RPPStateMachine("my_machine")
machine.e_run()

machine transitions to running state. _I do it in an indirect way because I wanted things to happen automatically. e_run() transitions to running and after run_machine calls initialize method of running class which fires an event to start up the chain of events. Below I show running and that clears thing up._

So the running state is defined as

from transitions.extensions import LockedHierarchicalMachine as Machine
from coordination.test_mode import TestingStateMachine
from coordination.release_mode import ReleaseStateMachine

class RunningStateMachine(Machine):
    def __init__(self):
        self._test_mode = TestingStateMachine()
        self._release_demo = ReleaseStateMachine()
        states = [
            "init",
            "configuration",
            "idle",
            {"name": "test_mode", "children": self._test_mode},
            {"name": "release_mode", "children": self._release_mode},
        ]

        Machine.__init__(self, states=states, initial="init")
        self.add_transition("e_start_running", "init", "configuration", after=self.configuration)
        self.add_transition("e_success_config", "configuration", "idle")
        self.add_transition("e_test_mode", "idle", "test_mode")
        self.add_transition("e_release_mode", "idle", "release_mode")
        self.add_transition("e_start_running", "idle", "init")

    def initialize(self):
        print("Initialization step for running, emitting e_start.")
        self.e_start_running()

    def configuration(self):
        print("Configuring...")
        print( "Current state: " + self.state)

        self.e_success_config()

which similar to its parent, is composed of a few states and a few substates. I have also enabled logging to see which states I enter and exit. To my experience, nesting state machines is very useful as you can reuse the states you have written before. Besides as your state machine grows, it helps to keep things more modular. So no state becomes huge and difficult to read/understand.

So the unusual behavior is that when e_run() is called I get prints of

INFO:transitions.core:Entered state running
INFO:transitions.core:Entered state running_init
Initialization step for running, emitting e_start.
INFO:transitions.core:Exited state init
INFO:transitions.core:Entered state configuration
Configuring...
current state: configuration
INFO:transitions.core:Exited state configuration
INFO:transitions.core:Entered state idle

As you see

machine.state
>>> 'running_init'

while

machine._running.state
>>> 'idle'

I can of course move the transition definitions to the parent state, but that's unhandy. I cannot do that for all sub-states. Obviously, I want each substate to be responsible for it's own behavior. What is the common practice? Is this a bug or intended behavior?

keivanzavari commented 5 years ago

I think I have figured out what the problem is (correct me if I am wrong please). Once you create a state with children (sub-state) as

self._running = RunningStateMachine()
{"name": "running", "children": self._running},

a copy of the sub-state is created. That's why machine._running and machine are in different states. Copying is fine by me, but the drawback is the following:

drawback

If there are callbacks inside the sub-state who are supposed to do stuff and then trigger events, they cannot do their job anymore. I can create a transition in the parent state as

self.add_transition("e_start_chain", "running_init", "running_configuration")

and trigger this event immediately after the call of e_run(). So I would do

machine.e_run() # machine state = running_init
machine.e_start_chain() # machine state = running_configuration

However, since configuration method in RunningStateMachine class has self.e_success_config(), triggering this event raises an error because the sub-state defined by machine._running is obviously in init state and I get the error of Can't trigger event e_success_config from state init!. But triggering machine.e_success_config() does the job.

still vague

So what is the right way to be able to fully reuse a machine as a sub-state of another? Does my parent machine need to inherit from the sub-state?

P.S. If the sub-state is copied, how deep does this copy go? Until it reaches the leaves? Or does it stop somewhere?

aleneum commented 5 years ago

Hello @keivanzavari,

To my experience, nesting state machines is very useful as you can reuse the states you have written before. Besides as your state machine grows, it helps to keep things more modular. So no state becomes huge and difficult to read/understand.

totally agree

I think I have figured out what the problem is (correct me if I am wrong please). Once you create a state with children (sub-state) [...] a copy of the sub-state is created.

yes, that's mentioned in the last paragraph of the reuse section:

Note that the HierarchicalMachine will not integrate the machine instance itself but the states and transitions by creating copies of them. This way you are able to continue using your previously created instance without interfering with the embedded version.

If the sub-state is copied, how deep does this copy go?

All states and substates will be integrated into the new Machine

So what is the right way to be able to fully reuse a machine as a sub-state of another?

I cannot claim this is the 'right' way because using machines in machines is a common practice for realizing HSMs afaik. So your initial thoughts are pretty much in line with how some state machine frameworks work. However, transitions' philosophy differs a bit here: we'd like to consider Machine instances to be the 'rulebooks' which defines transitions and configurations whereas the actual state-dependent behavior is part of the model.

Here is a rather abstract example about how to organized behavior and transitions in models and machines. More explanation below:

from transitions.extensions.factory import HierarchicalGraphMachine as HSM

# define a basic model; useful for generic error handling
class BaseBehaviour:

    def emergency_shutdown(self):
        print('something is wrong!')

# define a task-specific behaviour which will act as a model;
# such a model will contain all related callbacks
class BehaviourA(BaseBehaviour):

    def do_A(self):
        print('doing A')

    def prepare_A(self):
        print('prepare A')

# define another independent model
class BehaviourB(BaseBehaviour):

    def do_B(self):
        print('doing B')

# extend the previously defined behaviour
class ImprovedBehaviourA(BehaviourA):

    def do_A(self):
        super(ImprovedBehaviourA, self).do_A()
        print('post process A')

# the final model will be a combination of the previously defined models
class Agent(ImprovedBehaviourA, BehaviourB):
    pass

states_A = ['initial', 'A', 'done']
states_B = ['initial', 'B', 'done']
transitions_A = [{'trigger': 'do', 'source': 'initial', 'dest': 'A'},
                 {'trigger': 'do', 'source': 'A', 'dest': 'done', 'before': 'do_A'}]
transitions_B = [{'trigger': 'do', 'source': 'initial', 'dest': 'B'},
                 {'trigger': 'do', 'source': 'B', 'dest': 'done', 'before': 'do_B'}]

# initialized the individual models and pass them to their related machines
behaviourA = BehaviourA()
machine_A = HSM(model=behaviourA, states=states_A, transitions=transitions_A, initial='initial', title='Behaviour A',
                auto_transitions=False)
behaviourB = BehaviourB()
machine_B = HSM(model=behaviourB, states=states_B, transitions=transitions_B, initial='initial', title='Behaviour B',
                auto_transitions=False)

# stitch everything together 
states_agent = ['initial',
                {'name': 'behaviourA', 'children': machine_A,
                 'remap': {'done': 'initial'}},
                {'name': 'behaviourB', 'children': machine_B,
                 'remap': {'done': 'initial'}}]

transitions_agent = [['initA', 'initial', 'behaviourA'],
                     ['initB', 'initial', 'behaviourB']]

agent = Agent()
machine_agent = HSM(model=agent, states=states_agent, transitions=transitions_agent, initial='initial', title='Agent',
                    auto_transitions=False)

# Graphviz plots of all rather simple state machines. See below.
behaviourA.get_graph().draw('behaviourA.png', prog='dot')
behaviourB.get_graph().draw('behaviourB.png', prog='dot')
agent.get_graph().draw('agent.png', prog='dot')

assert agent.state == 'initial'
agent.initA()
assert agent.state == 'behaviourA_initial'
agent.do()
assert agent.state == 'behaviourA_A'
agent.do()
assert agent.state == 'initial'
agent.initB()
agent.do()
agent.do()
assert agent.state == 'initial'

Behaviour A

behavioura

Behaviour B

behaviourb

Agent (Combined Behaviour)

agent

Advantages

Separation of rules and behaviour -- models and states/transitions can be used independently of each other

Trigger events in callbacks -- events in submodules will be processed, can be overridden when necessary and will 'dispatch' to their parent in case they cannot be processed in the current context. This is handy when a specific event might end a subroutine but this event is not specified in the subroutine itself. See for instance the 'relax' event in the example notebook which exits the nested state independently of the current substate.

Drawbacks

Namespace collision -- As everything will end up in a (set of) macro models, methods with the same name will be overridden by inheritance. This can be desired for specialization but may also cause conflicts when generic callback names (e.g. 'on_event' ) are frequently used in submodels and transitions.

onenter -- Callbacks such as on_enter_stateX in submodels will not work anylonger when state names changes due to nesting.

keivanzavari commented 5 years ago

Thanks for the answer, much appreciated. In the meantime, I changed the implementation to just one machine that has all the states. So in the example above RunningStateMachine is incorporated into the parent state machine. If my state machine grows, I'll have to find ways to fix this. I have previously used rFSM and made quite complicated state machines. There it's very easy to load another state machine without having to know what's inside of it like here.

So I would very much like to use some smaller states as a sort of plug and play modules and do not have to tweak with them. If I understand you correctly, I'll have to load each state as a separate model, right? So for my example above it would be separate add_model calls. What if one state machine needs to use another?

aleneum commented 5 years ago

There it's very easy to load another state machine without having to know what's inside of it [...] So I would very much like to use some smaller states as a sort of plug and play modules and do not have to tweak with them.

I see the appeal of that. I'd assume it can be done with transitions as well. Since callbacks can also be function/method references instead of strings. The degree of isolation depends on how the module is defined:

class BehaviorA:
    def __init__(self):
        states = [{'name': 'A', 'on_enter': self.do_something}, ...]
        transitions = [{'trigger':'foo', ..., 'prepare': self.prepare,
                        'conditions': 'model_callback'}, ...]
        # initialized machine without model
        self.machine = Machine(model=None, states=states,
                               transitions=transitions, initial=...)

However, it might not be as convenient as it should be. Feel free to give feedback about your experience and how to improve the handling of nested states.

If I understand you correctly, I'll have to load each state as a separate model, right?

The states and transitions are defined for the machine. However, the model is the actual 'stateful' object and contains the state- and transition-related callbacks. I'd suggest to isolate context-specific behaviour into a machine-model unit.

aleneum commented 4 years ago

I created a feature draft based on this discussion and will close this issue for now. Thank you for your feedback! If you have a good test case for convenient nesting (with possible corner cases), feel free to post it here or in the issue mentioned above.