2

I am using pytransitions and have come across the need to have several states which are unrelated with others, and would make much sense to model using a non deterministic state machine, which is mathematically equivalent.

I would like something like the following

from transitions import Machine
from transitions import EventData


class Matter(object):
    def __init__(self):
        transitions1 = [
            {'trigger': 'heat', 'source': 'solid', 'dest': 'liquid'},
            {'trigger': 'heat', 'source': 'liquid', 'dest': 'gas'},
            {'trigger': 'cool', 'source': 'gas', 'dest': 'liquid'},
            {'trigger': 'cool', 'source': 'liquid', 'dest': 'solid'}
        ]

        transitions2 = [
            {'trigger': 'turn_on', 'source': 'off', 'dest': 'on'},
            {'trigger': 'turn_off', 'source': 'on', 'dest': 'off'},
        ]
        self.machine = Machine(
                model=self,
                states=[['solid', 'liquid', 'gas'], ['on', 'off']],
                transitions=[transitions1, transitions2],
                initial=['solid', 'off'],
                send_event=True
        )

    def on_enter_gas(self, event: EventData):
        print(f"entering gas from {event.transition.source}")

    def on_enter_liquid(self, event: EventData):
        print(f"entering liquid from {event.transition.source}")

    def on_enter_solid(self, event: EventData):
        print(f"entering solid from {event.transition.source}")

    def on_enter_on(self, event: EventData):
        print(f"entering on from {event.transition.source}")

    def on_enter_off(self, event: EventData):
        print(f"entering off from {event.transition.source}")

I could define a new set of states to be states=itertools.product(states1, states2) and then define all the transitions as the equivalence theorem shows.

I was wondering if this behavior is supported in the library and if so, how to achieve it.

I have more than just 2 sets of (mostly) independent states. Really, I have a bunch of toggles that occasionally have interactions, but mostly are independent.

Gulzar
  • 23,452
  • 27
  • 113
  • 201

1 Answers1

2

... to have several states which are unrelated with others, and would make much sense to model using a non deterministic state machine

for me this sounds like what you are looking for is not necessarily non-determinism but hierarchical/compound states and concurrency/parallelism.

You could make use of transitions Hierarchical State Machine extension that also features concurrency:

from transitions.extensions import HierarchicalMachine

states1 = ['solid', 'liquid', 'gas']
states2 = ['on', 'off']

transitions1 = [
    {'trigger': 'heat', 'source': 'solid', 'dest': 'liquid'},
    {'trigger': 'heat', 'source': 'liquid', 'dest': 'gas'},
    {'trigger': 'cool', 'source': 'gas', 'dest': 'liquid'},
    {'trigger': 'cool', 'source': 'liquid', 'dest': 'solid'}
]

transitions2 = [
    {'trigger': 'turn_on', 'source': 'off', 'dest': 'on'},
    {'trigger': 'turn_off', 'source': 'on', 'dest': 'off'},
]

combined_states = [
    {"name": "running", "parallel":
        [
            dict(name="component1", states=states1, transitions=transitions1, initial=states1[0]),
            dict(name="component2", states=states2, transitions=transitions2, initial=states2[0])
        ]
    }
]

m = HierarchicalMachine(states=combined_states, auto_transitions=False, initial="running")
print(m.state)  # >>> ['running_component1_solid', 'running_component2_on']
m.turn_off()
print(m.state)  # >>> ['running_component1_solid', 'running_component2_off']

However, HSMs are significantly more complex than simple Machines. The documentation mentions several restrictions considering naming conventions and nesting/initialization configurations that need to be followed.

This is why I usually try to find the simplest solution for my FSM architecture. Right now your nesting is rather flat and it could also be achieved with a set of models and Machines. The 'rulebook' approach of transitions makes it rather easy to manage multiple models in different states with just one machine and its 'dispatch' method:

from transitions import Machine


class Model:
    pass


class MultiMachine(Machine):

    def __init__(self, configurations):
        # Initialize the machine blank, no states, no transitions and
        # no initial states. Disable auto_transitions since there shouldn't
        # be the possibility to transition e.g. from 'on' to 'liquid'.
        # Furthermore, ignore_invalid_triggers so that events not considered
        # by a model will not throw an exception.
        super().__init__(model=None, states=[], transitions=[], initial=None, auto_transitions=False,
                         ignore_invalid_triggers=True)
        # create a model for each configuration
        for states, transitions, initial in configurations:
            self.add_states(states)
            self.add_transitions(transitions)
            self.add_model(Model(), initial=initial)

    @property
    def state(self):
        return [model.state for model in self.models]


m = MultiMachine([(states1, transitions1, 'solid'), (states2, transitions2, 'off')])
assert m.state == ['solid', 'off']
m.dispatch("turn_on")
assert m.state == ['solid', 'on']
m.dispatch("heat")
assert m.state == ['liquid', 'on']

From your comments:

How can I add a conditional transition in one sub-machine, based on the state in another? For example, heat should only make solid into gas in case of on? [...] HSMs, maybe it is better in this case.

This could be solved with HSMs by defining heat events only on source states on_*. However, if you have many of these dependent variables, the nesting could become quite complex. Instead you could add references to the other machine's is_<state> convenience functions to the condition list of all related transitions. This can be done after initialization in case bootstrapping is an issue:

from transitions import Machine
from transitions.core import Condition

states1 = ['solid', 'liquid', 'gas']
states2 = ['off', 'on']

m1 = Machine(states=states1, initial=states1[0],
             transitions=[{'trigger': 'heat', 'source': 'solid', 'dest': 'liquid'},
                          {'trigger': 'heat', 'source': 'liquid', 'dest': 'gas'},
                          {'trigger': 'cool', 'source': 'gas', 'dest': 'liquid'},
                          {'trigger': 'cool', 'source': 'liquid', 'dest': 'solid'}])
m2 = Machine(states=states2, initial=states2[0],
             transitions=[{'trigger': 'turn_on', 'source': 'off', 'dest': 'on'},
                          {'trigger': 'turn_off', 'source': 'on', 'dest': 'off'}])

# get all heat transitions and add the condition that they may only be valid when m2.is_on returns True
for trans in m1.get_transitions("heat"):
    trans.conditions.append(Condition(func=m2.is_on))
    # if you want to add an 'unless' statement pass `target=False`
    # to the condition. e.g. "heat unless m2 is off"
    # trans.conditions.append(Condition(func=m2.is_off, target=False))

assert m1.is_solid()
assert m2.is_off()
assert not m1.heat()
assert m1.is_solid()
assert m2.turn_on()
assert m1.heat()
assert m1.is_liquid()
aleneum
  • 2,083
  • 12
  • 29
  • Thanks! Can I add a transition do_magic from say ['solid', 'off'] to ['gas', 'on']? – Gulzar Jul 12 '21 at 16:02
  • 1
    that would be two transitions: ['do_magic', 'solid', 'gas'] and ['do_magic', 'off', 'on']. – aleneum Jul 12 '21 at 16:09
  • I'm sorry I am having trouble asking exactly what I need until I encounter it. How can I add a conditional transition in one sub-machine, based on the state in another? For example, `heat` should only make `solid` into `gas` in case of `on`? – Gulzar Jul 12 '21 at 18:50
  • Also, in that case, what will `on_enter` methods look like? And how will [this answer](https://stackoverflow.com/a/68269299/913098) have to change to accommodate for this kind of states? – Gulzar Jul 12 '21 at 18:58
  • I am starting to think what I really need here are several individual small machines that subscribe to each other's `on_enter` as needed. Just don't really know how to solve a circular initialization in this case. Could this be a correct direction? – Gulzar Jul 12 '21 at 19:04
  • Above comment written when trying to use method2 you proposed. I will try HSMs, maybe it is better in this case. – Gulzar Jul 12 '21 at 19:07
  • I extended my answer a bit. HSMs could work but could also become quite complex depending on the amount of conditions you need to consider. – aleneum Jul 13 '21 at 13:05
  • According to here and also it looks by the docs that using HSM would force me to give up trigger checks and would force usage of `ignore_invalid_triggers=True`, is that corrrect? – Gulzar Jul 13 '21 at 14:07
  • 1
    Whether `ignore_invalid_triggers` needs to be used or not depends on whether an invalid trigger is part of the common workflow (see `MultiMachine` example) or not (see `HSM` example) – aleneum Jul 13 '21 at 14:10
  • Also, unfortunately, my company's IT has blocked pip, so until that is dealt, I am stuck with version 0.7.1 which seems to not have parallel states in HSM. – Gulzar Jul 13 '21 at 14:30
  • I think that would leave me no choice but to instantiate some (sub) machines, and have an event forwarder machine with a single state to rule them all. – Gulzar Jul 13 '21 at 14:34
  • Let us [continue this discussion in chat](https://chat.stackoverflow.com/rooms/234828/discussion-between-aleneum-and-gulzar). – aleneum Jul 13 '21 at 17:09