pytransitions / transitions

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

Add capability to create graphs for any and for all states, not only the current state. #564

Closed Blindfreddy closed 2 years ago

Blindfreddy commented 2 years ago

Is your feature request related to a problem? Please describe. Currently, the get_graph() method always returns the diagram for the active state, and generating the diagram takes relatively long, making response times relatively large. To my knowledge, it is not possible to generate the diagram for another, inactive state as if that state were active.

Describe the solution you'd like I'd like to be able to generate diagrams for any state as if a given state were active, irrespective of the actual active state at the time. This would effectively highlight the given state instead of the active state.

Additional context Background My application relies on very short response times. When the user requests the current state diagram, it is computed on the fly, but generating the state diagram is too slow.

Motivation To improve response times, I would like to pre-compute diagrams for all possible states when the application bootstraps and store the diagrams in a dictionary holding state and the pre-computed diagram: {state : diagram}. That way, when the user requests the current state diagram after bootstrapping the application, it is simply fetched from the dict and not computed.

To be clear, the only difference between the pre-computed diagrams would be the highlighted active state.

Potential Solution In pseudo code it will be something like this:

state_dict = {}
for state in all_states:
   compute diagram for state as if that state were active and store in state_dict, e.g state_dict[state] = get_graph(current_state).draw(...)

then later on during runtime, instead of

diag = m.get_graph().draw() <-- this takes time

simply do

diag = state_dict[current_state] <-- this is much faster

So a potential solution could be to add a parameter state=None to get_graph (assuming we have control over it) which creates the state diagram as if state were currently active. If state is None behavior would be as-is, otherwise create the diagram as if state were active.

aleneum commented 2 years ago

Hello @Blindfreddy,

some background information concerning this:

The behavior of get_graph depends on whether you use pygraphviz or the graphviz backend. Usually get_graph will only recreate a graph when the force_new parameter is set to True. This is the case when new states and transitions have been added. The graphviz backend is rather simplistic and will not keep an editable state of the graph which means it basically regenerates a graph whenever get_graph is called, independently of whether force_new is set or not.

Caching sounds like a good idea. Changes to the graph are usually done via TransitionGraphSupport so I'd use a machine that does not depend on it. For instance, I'd use a GraphMachine for the creation of graphs and a custom Machine during runtime. There is no need to tinker with GraphMachine to create the graphs you need. You can access the BaseGraph instance, edit it according to your liking and save the result:

from transitions import Machine
from transitions.extensions.diagrams import GraphMachine

class CachedGraphsMachine(Machine):

    def __init__(self, *args, **kwargs):
        self.cached_graphs = {}
        super().__init__(*args, **kwargs)

    def get_graph(self, model_state):
        return self.cached_graphs[model_state]

states = ["A", "B", "C", "D"]
transitions = [
    ["go", ["A", "D"], "B"],
    ["do", "B", "C"],
    ["make", "B", "D"]
]

gm = GraphMachine(states=states, transitions=transitions, initial="A", auto_transitions=False, title="My Machine")
pm = CachedGraphsMachine(states=states, transitions=transitions, initial="A")
base_graph = gm.model_graphs[id(gm)]

for state in gm.states.values():
    base_graph.reset_styling()
    base_graph.set_node_style(state.name, "active")
    # In case you also want to set the last transition
    # base_graph.set_previous_transition("A", state.name)
    # Note that you cannot just pass `AGraph` or the graphviz pendant to the cache since the object will
    # change during looping.
    pm.cached_graphs[state.name] = base_graph.get_graph().draw(None, prog='dot', format='png')

pm.go()
# write the buffered byte array to disk to have a look
with open("current_state.png", "wb") as f:
    f.write(pm.get_graph(pm.state))
Blindfreddy commented 2 years ago

Thanks for your response, I think we can close this topic, now. Note, however that I made it work in a different way - perhaps that may be of interest to other users:

In my application, state_diagrams are accessed infrequently, so I chose a lazy caching approach tied to the machine.after_state_change event. When it fires, I call the method create_state_diagram, which adds the newly entered state's diagram to the cache in a separate thread, unless it already exists. That way, state changes are still practically immediate because only a simple check or kicking off the thread to generate the diagram are performed, and the associated state_diagram is then generated in the background. Retrieving a state_diagram is now practically immediate once generated. Only drawback is that the user gets an error if he retrieves the state_diagram whilst it is being generated. In that case, retrieving it again will succeed as soon as generating the diagram has completed.

Here is the code for anyone interested:

    def create_state_diagram(self, *args, **kwargs):
        """
        Callback on machine.after_state_change hence *args, **kwargs

        Create state diagram for current state unless it already exists.
        Implement lazy state diagram generation for fast retrieval, because
        generating the diagram on demand is usually slow compared to the 
        expected system response time, whereas retrieving and transmitting it
        is fast. Hence generate the diagram for each state when that state is 
        first entered. Do so in a separate thread, so that the state change is 
        not slowed down by the diagram generation.  Store diagram in private 
        variable self._diagrams_map, which is accessed by property
        state_diagram. On subsequent entries to same state, generating is 
        omitted.
        """

        def gen_diag():
            debug_log(self, msg + f"{current_thread()}")
            try:
                content = self._sm.get_graph().draw(None, format="png", 
                                                    prog="dot")
                d = {'name' : self.name,
                     'filename' : self.name + '_state_diagram.png',
                     'mimetype' : 'image/png',
                     'encoding' : 'base64',
                     'value' : b64encode(content).decode()}
                self._diagrams_map[self.state] = d
                debug_log(self, msg + "done.")
            except Exception as e:
                exc_log(self, msg + f"failed: {e}")                

        msg = info_log(self, f"create_state_diagram(unused args {args}, "
                        f"kwargs {kwargs}) in separate thread...")
        if self.state in self._diagrams_map:
            debug_log(self, msg + "omitted: already exists.")
        else:
            # Avoid multiple thread instances for the same state in the event
            # that state changes to the same state are faster than the creation
            # of the state diagram. Subsequent lookup to the state only checks
            # if the key is present, not its value. If the state diagram for 
            # given state is accessed before the thread finishes, the user 
            # will get a None and hopefully retry. By that time the diagram
            # should be finished and the thread replaces None with the actual
            # diagram. 
            self._diagrams_map[self.state] = None
            Thread(target=gen_diag, 
                   name="create_state_diagram_" + self.state + "_thread", 
                   daemon=True).start()
            debug_log(self, msg + "done (thread started).")