AlexandreDecan / sismic

Sismic Interactive Statechart Model Interpreter and Checker http://sismic.readthedocs.io/
GNU Lesser General Public License v3.0
141 stars 25 forks source link

Interpreter.Bind and multiple #124

Closed chansdad closed 3 months ago

chansdad commented 3 months ago

Here is the code that i am running. This is using the microwave.yaml from examples . When i interact with the statechart , what i am seeing is repeated events printed , the count of the messages is increasing on each interaction , is it possible or how can i restrict to only print that message once . ex, if the critical state is entered , i want to only emit or receive just once that the criticcal state has been entered. Below the code you can see the interactive session with this statechart instance.

### Code

from sismic.io import import_from_yaml
from sismic.interpreter import Interpreter

def list_possible_events(interpreter):
    current_states = interpreter.configuration
    transitions = interpreter.statechart.transitions
    possible_events = set()

    for transition in transitions:
        if transition.source in current_states:
            possible_events.add(transition.event)

    return possible_events

def event_handler(event):
    print("Received an event", event.name)

def interact_with_statechart(interpreter):
    interpreter.execute()  # Start the interpreter

    while True:
        print(f"Current state(s): {interpreter.configuration}")

        possible_events = list_possible_events(interpreter)
        print(f"Possible events: {possible_events}")

        event = input("Enter an event (or 'exit' to quit): ").strip()
        if event.lower() == 'exit':
            break

        interpreter.bind(event_handler)
        interpreter.queue(event)
        interpreter.execute()

        print(f"Event '{event}' processed.")

# Load the statechart from the provided YAML file
statechart_path = 'microwave.yaml'
statechart = import_from_yaml(filepath=statechart_path)
interpreter = Interpreter(statechart)

if __name__ == "__main__":
    interact_with_statechart(interpreter)

### Output

Current state(s): ['controller', 'door closed', 'closed without item']
Possible events: {'cooking_stop', 'door_opened'}
Enter an event (or 'exit' to quit): door_opened
Received an event lamp_switch_on
Event 'door_opened' processed.
Current state(s): ['controller', 'door opened', 'opened without item']
Possible events: {'cooking_stop', 'item_placed', 'door_closed'}
Enter an event (or 'exit' to quit): item_placed
Event 'item_placed' processed.
Current state(s): ['controller', 'door opened', 'opened with item']
Possible events: {'cooking_stop', 'item_removed', 'door_closed'}
Enter an event (or 'exit' to quit): door_closed
Received an event lamp_switch_off
Received an event lamp_switch_off
Received an event lamp_switch_off
Event 'door_closed' processed.
Current state(s): ['controller', 'door closed', 'closed with item', 'program mode', 'not ready']
Possible events: {'timer_dec', None, 'power_reset', 'cooking_stop', 'door_opened', 'timer_inc', 'power_dec', 'power_inc', 'timer_reset'}
Enter an event (or 'exit' to quit): door_opened
Received an event display_clear
Received an event display_clear
Received an event display_clear
Received an event display_clear
Received an event lamp_switch_on
Received an event lamp_switch_on
Received an event lamp_switch_on
Received an event lamp_switch_on
Event 'door_opened' processed.
Current state(s): ['controller', 'door opened', 'opened with item']
Possible events: {'cooking_stop', 'item_removed', 'door_closed'}
Enter an event (or 'exit' to quit): exit

So if you see the last interaction , when the door_opened is triggered , i see multiple lamp_switch_on as well as the display_clear events , i would like to only receive lamp_switch_on , granted , display_clear can also be received as that is defined on exit: send('display_clear') on door closed state , so if state is exiting door closed and entering door opened , i would like to see jsut one of each event , But is there any other option that will let me just receive only a single message when a specific state is entered? Further more based on the output , it seems like the entire interatcion with this statechart is retained in memory , can we clear the history? ex , if the door is closed and the microwave does not have an item placed , bringing it to a normal or initial state without any history as continued interaction can eat up memory isnt it? Appreciate your response.

AlexandreDecan commented 3 months ago

Hello,

Your callback (event handler) is called multiple times because you bind it in the loop. Bind it once before the loop and you'll have it called only once per event triggered.

AlexandreDecan commented 3 months ago

Also, look at the events_for method on a statechart to replace your list_possible_events : by passing the current configuration of the interpreter as argument, this method will provide all events that can be handled by these current states.

chansdad commented 3 months ago

thanks , so i moved the bind outside the while , thanks for pointing that out. Though while using the Interpreter.Queue and Interpreter.bind in a setting where i have a list of statechart instances i am seeing that the queue is not being drained and still holds the old events triggered My code is some thing like this in a flask application def event_handler(event): print("Received an event ------", event.name)

class StatechartManager: def init(self, app): self.app = app self.interpreters = {}

 def create_interpreter(self, statechart_id, instance_id):
    key = (statechart_id, instance_id)
    if key in self.interpreters:
        # Instance already exists, return False to indicate failure to create a new one
        return False

    statechart = self.load_statechart(statechart_id)
    if statechart:
        interpreter = Interpreter(statechart)
        interpreter.execute()
        self.interpreters[key] = interpreter
        return True
    return False

def get_interpreter(self, statechart_id, instance_id):
    return self.interpreters.get((statechart_id, instance_id))

if name == 'main': manager = StatechartManager(app) # Pass the app here app.run(debug=True)

in a trigger_event route i have

interpreter = manager.get_interpreter(statechart_id, instance_id)

interpreter.bind(event_handler)
#interpreter.execute() 

if interpreter:
    # Queue the event
    interpreter.context.update(event_parameters)
    event = Event(event_name, **event_parameters)
    interpreter.queue(event)
    interpreter.execute()

    print(" Event Processed")

when i call the trigger_event 4 times , i am seeing a total of 8 messages printed on the 4th execution while i am expecting just 2 most recent mesages .

Received an event ------ Exiting Critical State Received an event ------ Entering Normal State Event Processed 127.0.0.1 - - [23/Jun/2024 05:25:49] "POST /trigger_event HTTP/1.1" 200 - Received an event ------ Exiting Normal State Received an event ------ Exiting Normal State Received an event ------ Entering Critical State Received an event ------ Entering Critical State Event Processed 127.0.0.1 - - [23/Jun/2024 05:26:08] "POST /trigger_event HTTP/1.1" 200 - Received an event ------ Exiting Critical State Received an event ------ Exiting Critical State Received an event ------ Exiting Critical State Received an event ------ Entering Normal State Received an event ------ Entering Normal State Received an event ------ Entering Normal State Event Processed 127.0.0.1 - - [23/Jun/2024 05:26:54] "POST /trigger_event HTTP/1.1" 200 - Received an event ------ Exiting Normal State Received an event ------ Exiting Normal State Received an event ------ Exiting Normal State Received an event ------ Exiting Normal State Received an event ------ Entering Warning State Received an event ------ Entering Warning State Received an event ------ Entering Warning State Received an event ------ Entering Warning State Event Processed 127.0.0.1 - - [23/Jun/2024 05:27:04] "POST /trigger_event HTTP/1.1" 200 -

Any suggestions ?? how i can get messges of the most recent execution?

AlexandreDecan commented 3 months ago

It's likely that you called the bind method more than once. Consider the following piece of code you provided:

interpreter.bind(event_handler)
#interpreter.execute() 

if interpreter:
    # Queue the event
    interpreter.context.update(event_parameters)
    event = Event(event_name, **event_parameters)
    interpreter.queue(event)
    interpreter.execute()

    print(" Event Processed")

Look at the output of your execution: string "Event Processed" is displayed multiple times, so I guess that interpreter.bind is also called multiple times since it's part of the same block of code. This explains why the first time, you only see two events, then each event is duplicated the second time, then each event is x3 the third time, and so on.

chansdad commented 3 months ago

Hi , Let me clarify . Here is the output for a total of 4 requests

processing request # 1
Received an event that is being handled by event_handler ------ Exiting Normal State
Received an event that is being handled by event_handler ------ Entering Warning State
completed processing request #  1
127.0.0.1 - - [23/Jun/2024 13:07:09] "POST /trigger_event HTTP/1.1" 200 -
processing request # 2
Received an event that is being handled by event_handler ------ Exiting Warning State
Received an event that is being handled by event_handler ------ Exiting Warning State
Received an event that is being handled by event_handler ------ Entering Critical State
Received an event that is being handled by event_handler ------ Entering Critical State
completed processing request #  2
127.0.0.1 - - [23/Jun/2024 13:07:21] "POST /trigger_event HTTP/1.1" 200 -
processing request # 3
Received an event that is being handled by event_handler ------ Exiting Critical State
Received an event that is being handled by event_handler ------ Exiting Critical State
Received an event that is being handled by event_handler ------ Exiting Critical State
Received an event that is being handled by event_handler ------ Entering Normal State
Received an event that is being handled by event_handler ------ Entering Normal State
Received an event that is being handled by event_handler ------ Entering Normal State
completed processing request #  3
127.0.0.1 - - [23/Jun/2024 13:07:33] "POST /trigger_event HTTP/1.1" 200 -
processing request # 4
Received an event that is being handled by event_handler ------ Exiting Normal State
Received an event that is being handled by event_handler ------ Exiting Normal State
Received an event that is being handled by event_handler ------ Exiting Normal State
Received an event that is being handled by event_handler ------ Exiting Normal State
Received an event that is being handled by event_handler ------ Entering Critical State
Received an event that is being handled by event_handler ------ Entering Critical State
Received an event that is being handled by event_handler ------ Entering Critical State
Received an event that is being handled by event_handler ------ Entering Critical State
completed processing request #  4

Here is the YAML

statechart:
  name: Temperature Logger
  preamble: |
    NORMAL_THRESHOLD = 25
    WARNING_THRESHOLD = 30
    CRITICAL_THRESHOLD = 35
  root state:
    name: logger
    initial: normal
    states:
      - name: normal
        transitions:
          - event: temperature_update
            guard: temperature >= WARNING_THRESHOLD and temperature < CRITICAL_THRESHOLD
            target: warning
          - event: temperature_update
            guard: temperature >= CRITICAL_THRESHOLD
            target: critical
        on entry: |
          send('Entering Normal State')
        on exit: |
          send('Exiting Normal State')

      - name: warning
        transitions:
          - event: temperature_update
            guard: temperature < WARNING_THRESHOLD
            target: normal
          - event: temperature_update
            guard: temperature >= CRITICAL_THRESHOLD
            target: critical
        on entry: |
          send('Entering Warning State')
        on exit: |
          send('Exiting Warning State')

      - name: critical
        transitions:
          - event: temperature_update
            guard: temperature < WARNING_THRESHOLD
            target: normal
          - event: temperature_update
            guard: temperature <= CRITICAL_THRESHOLD and temperature >= WARNING_THRESHOLD 
            target: warning
        on entry: |
          send('Entering Critical State')

        on exit: |
          send('Exiting Critical State')

And here is the route

def send_event():
    data = request.json

    statechart_id = str(data.get('statechart_id'))  # Convert statechart_id to a string
    instance_id = str(data.get('instance_id'))  # Convert instance_id to a string
    event_name = str(data.get('event'))  # Convert event to a string
    event_parameters = data.get('parameters', {})
    request_no= str(data.get('request_no'))

    print ("processing request #" , request_no)
    if not statechart_id or not instance_id or not event_name:
        return jsonify({'error': 'Statechart ID, instance ID, and event are required'}), 400

    current_interpreter = manager.get_interpreter(statechart_id, instance_id)    
    if current_interpreter:
        #Bind event handler 
        current_interpreter.bind(event_handler)
        # Queue the event
        current_interpreter.context.update(event_parameters)
        event = Event(event_name, **event_parameters)
        current_interpreter.queue(event)
        current_interpreter.execute()

        current_states = current_interpreter.configuration
        possible_events = list_possible_events(current_interpreter)

        print("completed processing request # ", request_no)
        return jsonify({
            'message': f'Event {event} processed',
            'current_states': current_states,
            'possible_events': possible_events
        }), 200

    return jsonify({'error': 'Interpreter not found'}), 404

Here is the event handler

def event_handler(event):
    print("Received an event that is being handled by event_handler ------", event.name)

As you can see, seems like after the command in the queue is executed , queue is not clearing and seems to be adding up the events on each iteration or each execution of the Intrepreter

AlexandreDecan commented 3 months ago

Could you please try to reproduce on a simpler example to confirm the bug is in sismic?

chansdad commented 3 months ago

Yes , will try to reproduce on a simpler example and share the details , so for now , i am looking at iterating through the list of steps returned after Interpreter executes steps=interpreter.execute , and capturing the one event that i want to see . for example , when i am iterating through the steps using this code

current_steps=interpreter.execute
for index,macro_step in enumerate(current_steps):
            # print(f"MacroStep {index}:")
            # Assuming 'steps' is the correct attribute, replace 'micro_steps' with the correct attribute name
            for micro_step in getattr(macro_step, 'steps', []):
                # print(f"  MicroStep:")
                event = getattr(micro_step, 'event', 'No Event')
                transition = getattr(micro_step, 'transition', 'No Transition')
                entered_states = getattr(micro_step, 'entered_states', [])
                exited_states = getattr(micro_step, 'exited_states', [])
                sent_events = getattr(micro_step, 'sent_events', [])
                #Printing event details
                if event is not None:
                    #print(f"    Event: {event}")
                    # Check if the event is an InternalEvent and process accordingly
                    if isinstance(event, InternalEvent):
                        print("Event of Interest", event.name)
                        get_internal_event_details(event, instance_id, statechart_id)                        

                print(f"    Event: {event}")
                print(f"    Transition: {transition}")
                print(f"    Entered States: {', '.join(entered_states)}")
                print(f"    Exited States: {', '.join(exited_states)}")
                print(f"    Sent Events: {', '.join([str(event) for event in sent_events])}")

I am getting the following

Event: Event('temperature_update', temperature=35)
    Transition: warning -> temperature_update [temperature >= CRITICAL_THRESHOLD] -> critical
    Entered States: critical
    Exited States: warning
    Sent Events: InternalEvent('Exiting Warning State'), InternalEvent('Entering Critical State', text='Alert')
Event of Interest Exiting Warning State
    Event: InternalEvent('Exiting Warning State')
    Transition: None
    Entered States: 
    Exited States: 
    Sent Events: 
Event of Interest Entering Critical State
    **Event: InternalEvent('Entering Critical State', text='Process')**
    Transition: None
    Entered States: 
    Exited States: 
    Sent Events: 

Based on my message in the YAMl , if i have the text='Process', this is the event i am interested in so i have the get_internal_event_details method , that validates the text='Process' exists ont he event and does the required processing.

def get_internal_event_details(event, instance_id, statechart_id):
    if isinstance(event, InternalEvent):        
        for attr in dir(event):
                attr_value = getattr(event, attr)                
                if attr == 'text':  # Checking if text might be stored differently
                    print(f"Text: {attr_value}")
                    if (attr_value == 'Process'):
                        print("do the required processing for ", instance_id, statechart_id)
AlexandreDecan commented 3 months ago

I think I found why your first example doesn't work. As I thought, you're binding your event handler multiple time. The issue comes from:

current_interpreter = manager.get_interpreter(statechart_id, instance_id)    
if current_interpreter:
    #Bind event handler 
    current_interpreter.bind(event_handler)

You should call bind only once (e.g., when the interpreter is created for the first time) or you should unbind it afterwards. Here, assuming that an interpreter already exists, your manager.get_interpreter returns an interpreter on which you already bound your event handler.

chansdad commented 3 months ago

Thank you for pointing that out . i updated my StatechartManager so that binding process is at manager level .


class StatechartManager:
    def __init__(self):
        self.interpreters = {}
        self.bound_handlers = set()

    def bind_handler_to_interpreter(self, statechart_id, instance_id, handler):
        interpreter = self.get_interpreter(statechart_id, instance_id)
        if (statechart_id, instance_id) not in self.bound_handlers:
            interpreter.bind(handler)
            self.bound_handlers.add((statechart_id, instance_id))
            print("Handler bound.")
        else:
            print("Handler already bound.")

and binding in the route at the manager level with

manager.bind_handler_to_interpreter(statechart_id, instance_id, event_handler)

This seems to have resolved the multiple messages issue . Output is as expected

processing request # 1
Handler bound.
completed processing request #  1
127.0.0.1 - - [23/Jun/2024 14:58:43] "POST /trigger_event HTTP/1.1" 200 -
processing request # 2
Handler already bound.
completed processing request #  2
127.0.0.1 - - [23/Jun/2024 14:59:01] "POST /trigger_event HTTP/1.1" 200 -
processing request # 3
Handler already bound.
Received an event that is being handled by event_handler ------ Entering Critical State
completed processing request #  3

127.0.0.1 - - [23/Jun/2024 14:59:17] "POST /trigger_event HTTP/1.1" 200 -

Thanks again for all the support.

AlexandreDecan commented 3 months ago

You're welcome :-)