Przeglądaj źródła

More flexible and efficient output events. Proper canceling of timer events in the controller event queue instead of ignoring canceled events in the statechart (statechart should not execute a big step for a canceled event)

Joeri Exelmans 5 lat temu
rodzic
commit
03957f99e5

+ 7 - 5
examples/digitalwatch/run.py

@@ -18,19 +18,21 @@ def main():
     gui = DigitalWatchGUI(topLevel)
 
     def on_gui_event(event: str):
-        eventloop.add_input(Event(id=-1, name=event, port="in", params=[]))
+        controller.add_input(
+            timestamp=eventloop.now(), event_name=event, port="in", params=[])
         eventloop.interrupt()
 
     gui.controller.send_event = on_gui_event
 
-    def on_big_step(output):
-        for e in output:
+    def on_output(event: OutputEvent):
+        if event.port == "out":
             # print("out:", e.name)
             # the output event names happen to be functions on our GUI controller:
-            method = getattr(gui.controller, e.name)
+            method = getattr(gui.controller, event.name)
             method()
 
-    eventloop = EventLoop(cd, TkInterImplementation(tk), on_big_step)
+    controller = Controller(cd, on_output)
+    eventloop = EventLoop(controller, TkInterImplementation(tk))
 
     eventloop.start()
     tk.mainloop()

+ 1 - 2
src/sccd/action_lang/cmd/prompt.py

@@ -1,4 +1,3 @@
-import sys
 import readline
 import termcolor
 from sccd.action_lang.dynamic.memory import *
@@ -35,6 +34,6 @@ if __name__ == "__main__":
       print(e)
     except (LarkError, ModelError, SCCDRuntimeException) as e:
       print(e)
-    except KeyboardInterrupt:
+    except (KeyboardInterrupt, EOFError):
       print()
       exit()

+ 8 - 4
src/sccd/cd/globals.py

@@ -3,11 +3,16 @@ from sccd.util.namespace import *
 from sccd.util.duration import *
 from sccd.util.debug import *
 
+# Global values for all statecharts in a class diagram.
 class Globals:
   def __init__(self):
+    # All the event names in the model
     self.events = Namespace()
+
     self.inports = Namespace()
     self.outports = Namespace()
+
+    # All the duration literals occuring in action code expressions in the class diagram.
     self.durations: List[SCDurationLiteral] = []
 
     # The smallest unit for all durations in the model.
@@ -21,14 +26,14 @@ class Globals:
     # Ensure delta not too big
     if delta:
       if duration(0) < gcd_delta < delta:
-        raise Exception("Model contains duration deltas (smallest = %s) not representable with delta of %s." % (str(self.delta), str(delta)))
+        raise ModelError("Model contains duration deltas (smallest = %s) not representable with delta of %s." % (str(self.delta), str(delta)))
       else:
         self.delta = delta
     else:
       self.delta = gcd_delta
 
     if self.delta != duration(0):
-      # Secretly convert all durations to the same unit...
+      # Secretly convert all durations to integers of the same unit...
       for d in self.durations:
         d.opt = d.d // self.delta
     else:
@@ -36,5 +41,4 @@ class Globals:
         d.opt = 0
 
   def assert_ready(self):
-    if self.delta is None:
-      raise Exception("Globals not ready: durations not yet processed.")
+    assert self.delta is not None # init_durations() not yet called

+ 51 - 58
src/sccd/controller/controller.py

@@ -7,6 +7,9 @@ from sccd.controller.object_manager import *
 from sccd.util.debug import print_debug
 from sccd.cd.cd import *
 
+def _dummy_output_callback(output_event):
+    pass
+
 # The Controller class is a primitive that can be used to build backends of any kind:
 # Threads, integration with existing event loop, game loop, test framework, ...
 # The Controller class itself is NOT thread-safe.
@@ -16,17 +19,27 @@ class Controller:
     @dataclasses.dataclass(eq=False, frozen=True)
     class EventQueueEntry:
         __slots__ = ["event", "targets"]
-        event: Event
+        event: InternalEvent
         targets: List[Instance]
 
-    def __init__(self, cd: AbstractCD):
+
+    def __init__(self, cd: AbstractCD, output_callback: Callable[[List[OutputEvent]],None] = _dummy_output_callback):
+        cd.globals.assert_ready()
         self.cd = cd
-        self.object_manager = ObjectManager(cd)
-        self.queue: EventQueue[int, EventQueueEntry] = EventQueue()
 
         self.simulated_time = 0 # integer
 
-        self.cd.globals.assert_ready()
+        def schedule_after(after, event, instances):
+            entry = Controller.EventQueueEntry(event, instances)
+            self.queue.add(self.simulated_time + after, entry)
+            return entry
+
+        def cancel_after(entry):
+            self.queue.remove(entry)
+
+        self.object_manager = ObjectManager(cd, output_callback, schedule_after, cancel_after)
+
+        self.queue: EventQueue[int, EventQueueEntry] = EventQueue()
 
         if DEBUG:
             self.cd.print()
@@ -36,86 +49,66 @@ class Controller:
         self.run_until = self._run_until_w_initialize
 
 
-    def add_input(self, input: Event, time_offset: int):
-            if input.name == "":
-                raise Exception("Input event can't have an empty name.")
-        
-            try:
-                self.cd.globals.inports.get_id(input.port)
-            except KeyError as e:
-                raise Exception("No such port: '%s'" % input.port) from e
+    def get_model_delta(self) -> Duration:
+        return self.cd.globals.delta
 
-            try:
-                event_id = self.cd.globals.events.get_id(input.name)
-            except KeyError as e:
-                raise Exception("No such event: '%s'" % input.name) from e
+    def _schedule(self, timestamp: int, event: InternalEvent, instances: List[Instance]):
+        self.queue.add(timestamp, Controller.EventQueueEntry(event, instances))
 
-            input.id = event_id
+    def _inport_to_instances(self, port: str) -> List[Instance]:
+        try:
+            self.cd.globals.inports.get_id(port)
+        except KeyError as e:
+            raise Exception("No such port: '%s'" % port) from e
 
-            # For now, add events received on input ports to all instances.
-            # In the future, we can optimize this by keeping a mapping from port name to a list of instances
-            # potentially responding to the event
-            self.queue.add(self.simulated_time + time_offset,
-                Controller.EventQueueEntry(input, self.object_manager.instances))
+        # For now, we just broadcast all input events.
+        # We don't even check if the event is allowed on the input port.
+        # TODO: multicast event only to instances that subscribe to this port.
+        return self.object_manager.instances
+
+    def add_input(self, timestamp: int, port: str, event_name: str, params = []):
+        try:
+            event_id = self.cd.globals.events.get_id(event_name)
+        except KeyError as e:
+            raise Exception("No such event: '%s'" % event_name) from e
+
+        instances = self._inport_to_instances(port)
+        event = InternalEvent(event_id, event_name, params)
+
+        self._schedule(timestamp, event, instances)
 
     # Get timestamp of next entry in event queue
     def next_wakeup(self) -> Optional[int]:
         return self.queue.earliest_timestamp()
 
-    # Returns duration since start
-    def get_simulated_duration(self) -> Duration:
-        return (self.cd.globals.delta * self.simulated_time)
-
-    def _run_until_w_initialize(self, now: Optional[int], pipe: queue.Queue):
+    def _run_until_w_initialize(self, now: Optional[int]):
         # first run...
         # initialize the object manager, in turn initializing our default class
         # and adding the generated events to the queue
         for i in self.object_manager.instances:
-            events = i.initialize()
-            self._process_big_step_output(events, pipe)
-        print_debug("initialized. time is now %s" % str(self.get_simulated_duration()))
+            i.initialize()
+        if DEBUG:
+            print("initialized.")
 
         # Next call to 'run_until' will call '_run_until'
         self.run_until = self._run_until
 
         # Let's try it out :)
-        self.run_until(now, pipe)
+        self.run_until(now)
 
     # Run until the event queue has no more due events wrt given timestamp and until all instances are stable.
     # If no timestamp is given (now = None), run until event queue is empty.
-    def _run_until(self, now: Optional[int], pipe: queue.Queue):
+    def _run_until(self, now: Optional[int]):
         # Actual "event loop"
         for timestamp, entry in self.queue.due(now):
             if timestamp != self.simulated_time:
                 # make time leap
                 self.simulated_time = timestamp
-                print_debug("\ntime is now %s" % str(self.get_simulated_duration()))
+                if DEBUG:
+                    print("\ntime is now %s" % str(self.cd.globals.delta * self.simulated_time))
             # run all instances for whom there are events
             for instance in entry.targets:
-                output = instance.big_step([entry.event])
+                instance.big_step([entry.event])
                 # print_debug("completed big step (time = %s)" % str(self.cd.globals.delta * self.simulated_time))
-                self._process_big_step_output(output, pipe)
 
         self.simulated_time = now
-
-    # Helper. Put big step output events in the event queue or add them to the right output listeners.
-    def _process_big_step_output(self, events: List[OutputEvent], pipe: queue.Queue):
-        pipe_events = []
-        for e in events:
-            if isinstance(e.target, InstancesTarget):
-                # offset = self._duration_to_time_offset(e.time_offset)
-                offset = e.time_offset
-                self.queue.add(self.simulated_time + offset, Controller.EventQueueEntry(e.event, e.target.instances))
-            elif isinstance(e.target, OutputPortTarget):
-                assert (e.time_offset == duration(0)) # cannot combine 'after' with 'output port'
-                pipe_events.append(e.event)
-            else:
-                raise Exception("Unexpected type:", e.target)
-        if pipe_events:
-            pipe.put(pipe_events, block=True, timeout=None)
-
-    # Helper
-    def _duration_to_time_offset(self, d: Duration) -> int:
-        if self.cd.globals.delta == duration(0):
-            return 0
-        return d // self.cd.globals.delta

+ 2 - 84
src/sccd/controller/event_queue.py

@@ -5,7 +5,6 @@ from collections import deque
 from  sccd.util import timer
 
 Timestamp = TypeVar('Timestamp')
-
 Item = TypeVar('Item')
 
 class EventQueue(Generic[Timestamp, Item]):
@@ -36,15 +35,15 @@ class EventQueue(Generic[Timestamp, Item]):
             self.counters[timestamp] = self.counters.setdefault(timestamp, 0) + 1
             def_event = (timestamp, self.counters[timestamp], item)
             heappush(self.queue, def_event)
-            return def_event
     
     def remove(self, item: Item):
         with timer.Context("event_queue"):
             self.removed.add(item)
             if len(self.removed) > 100:
                 self.queue = [x for x in self.queue if x not in self.removed]
+                heapify(self.queue)
                 self.removed = set()
-    
+
     # Raises exception if called on empty queue
     def pop(self) -> Tuple[Timestamp, Item]:
         with timer.Context("event_queue"):
@@ -65,84 +64,3 @@ class EventQueue(Generic[Timestamp, Item]):
     def due(self, timestamp: Optional[Timestamp]) -> Generator[Tuple[Timestamp, Item], None, None]:
         while self.is_due(timestamp):
             yield self.pop()
-
-# Alternative implementation: A heapq with unique entries for each timestamp, and a deque with items for each timestamp.
-class EventQueueDeque(Generic[Timestamp, Item]):
-
-    def __init__(self):
-        self.queue: List[Tuple[Timestamp, Deque[Item]]] = []
-        self.entries: Dict[Timestamp, Deque[Item]] = {}
-
-        # performance optimization:
-        # removed items are not immediately removed from the queue,
-        # instead they are added to the following set:
-        self.removed: Set[Item] = set()
-
-    def is_empty(self) -> bool:
-        for x in self.queue:
-            for y in x[1]:
-                if y not in self.removed:
-                    return False
-        return True
-
-    def earliest_timestamp(self) -> Optional[Timestamp]:
-        try:
-            earliest, _ = self.queue[0]
-            return earliest
-        except IndexError:
-            return None
-
-    def add(self, timestamp: Timestamp, item: Item):
-        with timer.Context("event_queue"):
-            try:
-                # entry at timestamp already exists:
-                self.entries[timestamp].append(item)
-            except KeyError:
-                # need to create entry
-                d = deque([item])
-                self.entries[timestamp] = d
-                heappush(self.queue, (timestamp, d))
-
-    def remove(self, item: Item):
-        with timer.Context("event_queue"):
-            self.removed.add(item)
-            if len(self.removed) > 5:
-                # to remove some elements safely from list while iterating over it and without copying the list,
-                # we iterate backwards:
-                for i in range(len(self.queue)-1, -1, -1):
-                    queue_entry = self.queue[i]
-                    timestamp, old_deque = queue_entry
-                    new_deque: Deque[Item] = deque([])
-                    for item in old_deque:
-                        if item not in self.removed:
-                            new_deque.append(item)
-                    if not new_deque:
-                        del self.entries[timestamp]
-                        del self.queue[i]
-                    else:
-                        self.queue[i] = (timestamp, new_deque)
-                # not sure if the heap invariant maintained here, though
-                # if not, uncomment:
-                # heapify(self.queue)
-                self.removed = set()
-
-    # Raises exception if called on empty queue
-    def pop(self) -> Tuple[Timestamp, Item]:
-        with timer.Context("event_queue"):
-            while True:
-                timestamp, d = self.queue[0]
-                while True:
-                    item = d.popleft()
-                    if not d: # deque empty - get rid of entry
-                        del self.entries[timestamp]
-                        heappop(self.queue)
-                    if item not in self.removed:
-                        return (timestamp, item)
-                    else:
-                        self.removed.remove(item)
-
-    # Safe to call on empty queue
-    # Safe to call other methods on the queue while the returned generator exists
-    def due(self, timestamp: Timestamp) -> Generator[Tuple[Timestamp, Item], None, None]:
-        while len(self.queue) and (timestamp == None or self.queue[0][0] <= timestamp):
-            yield self.pop()

+ 37 - 33
src/sccd/controller/object_manager.py

@@ -1,46 +1,50 @@
 import re
 import abc
 from typing import List, Tuple
-from sccd.statechart.dynamic.statechart_instance import *
+from sccd.statechart.dynamic.statechart_instance import InternalEvent, Instance, StatechartInstance
 
 # TODO: Clean this mess up. Look at all object management operations and see how they can be improved.
 class ObjectManager(Instance):
+    __slots__ = ["cd", "output_callback", "schedule_callback", "instances"]
+
     _regex_pattern = re.compile("^([a-zA-Z_]\w*)(?:\[(\d+)\])?$")
 
-    def __init__(self, cd):
+    def __init__(self, cd, output_callback, schedule_callback, cancel_callback):
         self.cd = cd
+        self.output_callback = output_callback
+        self.schedule_callback = schedule_callback
+        self.cancel_callback = cancel_callback
 
         # set of all instances in the runtime
         # we need to maintain this set in order to do broadcasts
         self.instances = [self] # object manager is an instance too!
 
-        i = StatechartInstance(cd.get_default_class(), self)
+        i = StatechartInstance(cd.get_default_class(), self, self.output_callback, self.schedule_callback, self.cancel_callback)
         self.instances.append(i)
 
     def _create(self, class_name) -> StatechartInstance:
-        # Instantiate the model for each class at most once:
-        # The model is shared between instances of the same type.
-        statechart = self.cd.classes[class_name]
-        i = StatechartInstance(statechart, self)
+        statechart_model = self.cd.classes[class_name]
+        i = StatechartInstance(statechart_model, self, self.output_callback, self.schedule_callback, self.cancel_callback)
         self.instances.append(i)
         return i
 
-    def initialize(self) -> List[OutputEvent]:
-        return []
+    def initialize(self):
+        pass
 
     # Implementation of super class: Instance
-    def big_step(self, input_events: List[Event]) -> List[OutputEvent]:
-        output = []
-        for e in input_events:
-            try:
-                o = ObjectManager._handlers[e.name](self, timestamp, e.parameters)
-                if isinstance(o, OutputEvent):
-                    output.append(o)
-                elif isinstance(o, list):
-                    output.extend(o)
-            except KeyError:
-                pass
-        return output
+    def big_step(self, input_events: List[InternalEvent]):
+        pass
+        # output = []
+        # for e in input_events:
+        #     try:
+        #         o = ObjectManager._handlers[e.name](self, timestamp, e.parameters)
+        #         if isinstance(o, OutputEvent):
+        #             output.append(o)
+        #         elif isinstance(o, list):
+        #             output.extend(o)
+        #     except KeyError:
+        #         pass
+        # return output
 
     # def _assoc_ref(self, input_string) -> List[Tuple[str,int]]:
     #     if len(input_string) == 0:
@@ -59,10 +63,10 @@ class ObjectManager(Instance):
     #             raise AssociationReferenceException("Invalid entry in association reference. Input string: " + input_string)
     #     return result
             
-    def _handle_broadcast(self, timestamp, parameters) -> OutputEvent:
-        if len(parameters) != 2:
-            raise ParameterException ("The broadcast event needs 2 parameters (source of event and event name).")
-        return OutputEvent(parameters[1], InstancesTarget(self.instances))
+    # def _handle_broadcast(self, timestamp, parameters) -> OutputEvent:
+    #     if len(parameters) != 2:
+    #         raise ParameterException ("The broadcast event needs 2 parameters (source of event and event name).")
+    #     return OutputEvent(parameters[1], InstancesTarget(self.instances))
 
     # def _handle_create(self, timestamp, parameters) -> List[OutputEvent]:
     #     if len(parameters) < 2:
@@ -229,11 +233,11 @@ class ObjectManager(Instance):
     #         currents = nexts
     #     return currents
 
-    _handlers = {
-        # "narrow_cast": _handle_narrowcast,
-        "broad_cast": _handle_broadcast,
-        # "create_instance": _handle_create,
-        # "associate_instance": _handle_associate,
-        # "disassociate_instance": _handle_disassociate,
-        # "delete_instance": _handle_delete
-    }
+    # _handlers = {
+    #     # "narrow_cast": _handle_narrowcast,
+    #     "broad_cast": _handle_broadcast,
+    #     # "create_instance": _handle_create,
+    #     # "associate_instance": _handle_associate,
+    #     # "disassociate_instance": _handle_disassociate,
+    #     # "delete_instance": _handle_delete
+    # }

+ 12 - 23
src/sccd/realtime/eventloop.py

@@ -20,28 +20,21 @@ class EventLoopImplementation(ABC):
 
 
 class EventLoop:
-    def __init__(self, cd: AbstractCD, event_loop: EventLoopImplementation, output_callback: Callable[[List[Event]],None], time_impl: TimeImplementation = DefaultTimeImplementation):
-        self.timer = Timer(time_impl, unit=cd.globals.delta) # will give us timestamps in model unit
-        self.controller = Controller(cd)
+    # def __init__(self, cd: AbstractCD, event_loop: EventLoopImplementation, output_callback: Callable[[List[Event]],None], time_impl: TimeImplementation = DefaultTimeImplementation):
+    def __init__(self, controller: Controller, event_loop: EventLoopImplementation, time_impl: TimeImplementation = DefaultTimeImplementation):
+        delta = controller.get_model_delta()
+        self.timer = Timer(time_impl, unit=delta) # will give us timestamps in model unit
+        # self.controller = Controller(cd)
+        self.controller = controller
         self.event_loop = event_loop
-        self.output_callback = output_callback
 
-        self.event_loop_convert = lambda x: int(get_conversion_f(
-            cd.globals.delta, event_loop.time_unit())(x)) # got to convert from model unit to eventloop native unit for scheduling
+        # got to convert from model unit to eventloop native unit for scheduling
+        self.event_loop_convert = lambda x: int(get_conversion_f(delta, event_loop.time_unit())(x))
 
         self.scheduled = None
-        self.queue = queue.Queue()
 
     def _wakeup(self):
-        self.controller.run_until(self.timer.now(), self.queue)
-
-        # process output
-        try:
-            while True:
-                big_step_output = self.queue.get_nowait()
-                self.output_callback(big_step_output)
-        except queue.Empty:
-            pass
+        self.controller.run_until(self.timer.now())
 
         # back to sleep
         now = self.timer.now()
@@ -58,19 +51,15 @@ class EventLoop:
         self.timer.start()
         self._wakeup()
 
+    def now(self):
+        return self.timer.now()
+
     # Uncomment if ever needed:
     # Does not mix well with interrupt().
     # def pause(self):
     #     self.timer.pause()
     #     self.event_loop.cancel()(self.scheduled)
 
-    # Add input. Does not automatically 'wake up' the controller if it is sleeping.
-    # If you want the controller to respond immediately, call 'interrupt'.
-    def add_input(self, event: Event):
-        # If the controller is sleeping, it's simulated time value may be in the past, but we want to make it look like the event arrived NOW, so from the controller's point of view, in the future:
-        offset = self.timer.now() - self.controller.simulated_time
-        self.controller.add_input(event, offset)
-
     def interrupt(self):
         if self.scheduled:
             self.event_loop.cancel(self.scheduled)

+ 78 - 36
src/sccd/statechart/dynamic/event.py

@@ -4,57 +4,99 @@ from abc import *
 from typing import *
 from sccd.util.duration import *
 
-# A raised event.
-class Event:
-    __slots__ = ["id", "name", "port", "params"]
+# An event that can cause transitions to happen.
+# Input events are internal events too.
+@dataclass
+class InternalEvent:
+    __slots__ = ["id", "name", "params"]
 
-    def __init__(self, id, name, port = "", params = []):
-        self.id: int = id
-        self.name: str = name
-        self.port: str = port
-        self.params: List[Any] = params
+    id: int
+    name: str # solely used for pretty printing
+    params: List[Any]
 
+    def __str__(self):
+        s = "Event("+self.name
+        if self.params:
+            s += str(self.params)
+        s += ")"
+        return termcolor.colored(s, 'yellow')
+
+    __repr__ = __str__
+
+
+
+@dataclass
+class OutputEvent:
+    __slots__ = ["port", "name", "params"]
+
+    port: str
+    name: str
+    params: List[Any]
+
+    # Compare by value
     def __eq__(self, other):
-        return self.id == other.id and self.port == other.port and self.params == other.params
+        return self.port == other.port and self.name == other.name and self.params == other.params
 
     def __str__(self):
-        if self.port:
-            s = "Event("+self.port+"."+self.name
-        else:
-            s = "Event("+self.name
+        s = "OutputEvent("+self.port+"."+self.name
         if self.params:
             s += str(self.params)
         s += ")"
         return termcolor.colored(s, 'yellow')
 
-    def __repr__(self):
-        return self.__str__()
+    __repr__ = __str__
 
-# Abstract class.
-class EventTarget(ABC):
-    __slots__ = []
+# # A raised event.
+# class Event:
+#     __slots__ = ["id", "name", "port", "params"]
 
-    @abstractmethod
-    def __init__(self):
-        pass
+#     def __init__(self, id, name, port = "", params = []):
+#         self.id: int = id
+#         self.name: str = name
+#         self.port: str = port
+#         self.params: List[Any] = params
 
-# A raised output event with a target and a time offset.
-class OutputEvent:
-    __slots__ = ["event", "target", "time_offset"]
+#     def __eq__(self, other):
+#         return self.id == other.id and self.port == other.port and self.params == other.params
+
+#     def __str__(self):
+#         if self.port:
+#             s = "Event("+self.port+"."+self.name
+#         else:
+#             s = "Event("+self.name
+#         if self.params:
+#             s += str(self.params)
+#         s += ")"
+#         return termcolor.colored(s, 'yellow')
+
+#     def __repr__(self):
+#         return self.__str__()
+
+# # Abstract class.
+# class EventTarget(ABC):
+#     __slots__ = []
+
+#     @abstractmethod
+#     def __init__(self):
+#         pass
+
+# # A raised output event with a target and a time offset.
+# class OutputEvent:
+#     __slots__ = ["event", "target", "time_offset"]
 
-    def __init__(self, event: Event, target: EventTarget, time_offset: int = (0)):
-        self.event = event
-        self.target = target
-        self.time_offset = time_offset
+#     def __init__(self, event: Event, target: EventTarget, time_offset: int = (0)):
+#         self.event = event
+#         self.target = target
+#         self.time_offset = time_offset
 
-class OutputPortTarget(EventTarget):
-    __slots__ = ["outport"]
+# class OutputPortTarget(EventTarget):
+#     __slots__ = ["outport"]
 
-    def __init__(self, outport: str):
-        self.outport = outport
+#     def __init__(self, outport: str):
+#         self.outport = outport
 
-class InstancesTarget(EventTarget):
-    __slots__ = ["instances"]
+# class InstancesTarget(EventTarget):
+#     __slots__ = ["instances"]
 
-    def __init__(self, instances):
-        self.instances = instances
+#     def __init__(self, instances):
+#         self.instances = instances

+ 6 - 6
src/sccd/statechart/dynamic/round.py

@@ -27,7 +27,7 @@ class CandidatesGenerator(ABC):
     cache: Dict[Tuple[Bitmap,Bitmap], List[Transition]] = field(default_factory=dict)
 
     @abstractmethod
-    def generate(self, state, enabled_events: List[Event], forbidden_arenas: Bitmap) -> Iterable[Transition]:
+    def generate(self, state, enabled_events: List[InternalEvent], forbidden_arenas: Bitmap) -> Iterable[Transition]:
         pass
 
 class CandidatesGeneratorCurrentConfigBased(CandidatesGenerator):
@@ -40,7 +40,7 @@ class CandidatesGeneratorCurrentConfigBased(CandidatesGenerator):
             candidates.reverse()
         return candidates
 
-    def generate(self, state, enabled_events: List[Event], forbidden_arenas: Bitmap) -> Iterable[Transition]:
+    def generate(self, state, enabled_events: List[InternalEvent], forbidden_arenas: Bitmap) -> Iterable[Transition]:
         events_bitmap = Bitmap.from_list(e.id for e in enabled_events)
         key = (state.configuration, forbidden_arenas)
 
@@ -72,7 +72,7 @@ class CandidatesGeneratorEventBased(CandidatesGenerator):
             candidates.reverse()
         return candidates
 
-    def generate(self, state, enabled_events: List[Event], forbidden_arenas: Bitmap) -> Iterable[Transition]:
+    def generate(self, state, enabled_events: List[InternalEvent], forbidden_arenas: Bitmap) -> Iterable[Transition]:
         events_bitmap = bm_from_list(e.id for e in enabled_events)
         key = (events_bitmap, forbidden_arenas)
 
@@ -121,13 +121,13 @@ class Round(ABC):
     def _run(self, forbidden_arenas: Bitmap) -> RoundResult:
         pass
 
-    def add_remainder_event(self, event: Event):
+    def add_remainder_event(self, event: InternalEvent):
         self.remainder_events.append(event)
 
-    def add_next_event(self, event: Event):
+    def add_next_event(self, event: InternalEvent):
         self.next_events.append(event)
 
-    def enabled_events(self) -> List[Event]:
+    def enabled_events(self) -> List[InternalEvent]:
         if self.parent:
             return self.remainder_events + self.parent.enabled_events()
         else:

+ 15 - 30
src/sccd/statechart/dynamic/statechart_execution.py

@@ -10,13 +10,14 @@ from sccd.util import timer
 # Set of current states etc.
 class StatechartExecution:
 
-    def __init__(self, statechart: Statechart):
+    def __init__(self, instance, statechart: Statechart):
+        self.instance = instance
         self.statechart = statechart
 
         self.gc_memory = None
         self.rhs_memory = None
         self.raise_internal = None
-        self.raise_next_bs = None
+        self.raise_output = None
 
         # set of current states
         self.configuration: Bitmap = Bitmap()
@@ -25,20 +26,15 @@ class StatechartExecution:
         # By default, if the parent of a history state has never been exited before, the parent's default states should be entered.
         self.history_values: List[Bitmap] = [h.parent.opt.ts_static for h in statechart.tree.history_states]
 
-        # For each AfterTrigger in the statechart tree, we keep an expected 'id' that is
-        # a parameter to a future 'after' event. This 'id' is incremented each time a timer
-        # is started, so we only respond to the most recent one.
-        self.timer_ids = [-1] * len(statechart.tree.after_triggers)
-
-        # output events accumulate here until they are collected
-        self.output = []
+        # Scheduled IDs for after triggers
+        self.timer_ids = [None] * len(statechart.tree.after_triggers)
 
     # enter default states
     def initialize(self):
         states = self.statechart.tree.root.opt.ts_static
         self.configuration = states
 
-        ctx = EvalContext(current_state=self, events=[], memory=self.rhs_memory)
+        ctx = EvalContext(execution=self, events=[], memory=self.rhs_memory)
         if self.statechart.datamodel is not None:
             self.statechart.datamodel.exec(self.rhs_memory)
 
@@ -55,7 +51,7 @@ class StatechartExecution:
         return (self.statechart.tree.state_list[id] for id in id_iter)
 
     # events: list SORTED by event id
-    def fire_transition(self, events: List[Event], t: Transition):
+    def fire_transition(self, events: List[InternalEvent], t: Transition):
         try:
             with timer.Context("transition"):
                 # Sequence of exit states is the intersection between set of current states and the arena's descendants.
@@ -69,7 +65,7 @@ class StatechartExecution:
                     enter_ids = t.opt.enter_states_static | reduce(lambda x,y: x|y, (self.history_values[s.history_id] for s in t.opt.enter_states_dynamic), Bitmap())
                     enter_set = self._ids_to_states(bm_items(enter_ids))
 
-                ctx = EvalContext(current_state=self, events=events, memory=self.rhs_memory)
+                ctx = EvalContext(execution=self, events=events, memory=self.rhs_memory)
 
                 print_debug("fire " + str(t))
 
@@ -80,6 +76,7 @@ class StatechartExecution:
                         # remember which state(s) we were in if a history state is present
                         for h, mask in s.opt.history:
                             self.history_values[h.history_id] = exit_ids & mask
+                        self._cancel_timers(s.opt.after_triggers)
                         self._perform_actions(ctx, s.exit)
                         self.configuration &= ~s.opt.state_id_bitmap
 
@@ -109,16 +106,10 @@ class StatechartExecution:
 
     def check_guard(self, t, events) -> bool:
         try:
-            # Special case: after trigger
-            if isinstance(t.trigger, AfterTrigger):
-                e = [e for e in events if bit(e.id) & t.trigger.enabling_bitmap][0] # it's safe to assume the list will contain one element cause we only check a transition's guard after we know it may be enabled given the set of events
-                if self.timer_ids[t.trigger.after_id] != e.params[0]:
-                    return False
-
             if t.guard is None:
                 return True
             else:
-                ctx = EvalContext(current_state=self, events=events, memory=self.gc_memory)
+                ctx = EvalContext(execution=self, events=events, memory=self.gc_memory)
                 self.gc_memory.push_frame(t.scope)
                 # Guard conditions can also refer to event parameters
                 if t.trigger:
@@ -141,13 +132,12 @@ class StatechartExecution:
     def _start_timers(self, triggers: List[AfterTrigger]):
         for after in triggers:
             delay: Duration = after.delay.eval(
-                EvalContext(current_state=self, events=[], memory=self.gc_memory))
-            timer_id = self._next_timer_id(after)
-            self.raise_next_bs(Event(id=after.id, name=after.name, params=[timer_id]), delay)
+                EvalContext(execution=self, events=[], memory=self.gc_memory))
+            self.timer_ids[after.after_id] = self.schedule_callback(delay, InternalEvent(id=after.id, name=after.name, params=[]), [self.instance])
 
-    def _next_timer_id(self, trigger: AfterTrigger):
-        self.timer_ids[trigger.after_id] += 1
-        return self.timer_ids[trigger.after_id]
+    def _cancel_timers(self, triggers: List[AfterTrigger]):
+        for after in triggers:
+            self.cancel_callback(self.timer_ids[after.after_id])
 
     # Return whether the current configuration includes ALL the states given.
     def in_state(self, state_strings: List[str]) -> bool:
@@ -158,8 +148,3 @@ class StatechartExecution:
         # else:
         #     print_debug("not in state"+str(state_strings))
         return in_state
-
-    def collect_output(self) -> List[OutputEvent]:
-        output = self.output
-        self.output = []
-        return output

+ 20 - 16
src/sccd/statechart/dynamic/statechart_instance.py

@@ -12,11 +12,11 @@ from sccd.statechart.dynamic.memory_snapshot import *
 # Interface for all instances and also the Object Manager
 class Instance(ABC):
     @abstractmethod
-    def initialize(self) -> List[OutputEvent]:
+    def initialize(self):
         pass
 
     @abstractmethod
-    def big_step(self, input_events: List[Event]) -> List[OutputEvent]:
+    def big_step(self, input_events: List[InternalEvent]):
         pass
 
 # Hardcoded limit on number of sub-rounds of combo and big step to detect never-ending superrounds.
@@ -24,10 +24,11 @@ class Instance(ABC):
 LIMIT = 100
 
 class StatechartInstance(Instance):
-    def __init__(self, statechart: Statechart, object_manager):
-        self.object_manager = object_manager
+    def __init__(self, statechart: Statechart, object_manager, output_callback, schedule_callback, cancel_callback):
+        # self.object_manager = object_manager
+        self.output_callback = output_callback
 
-        self.execution = StatechartExecution(statechart)
+        self.execution = StatechartExecution(self, statechart)
 
         semantics = statechart.semantics
 
@@ -76,13 +77,13 @@ class StatechartInstance(Instance):
         # Event lifeline semantics
 
         def whole(input):
-            self._big_step.remainder_events = input
+            self._big_step.remainder_events.extend(input)
 
         def first_combo(input):
-            combo_step.remainder_events = input
+            combo_step.remainder_events.extend(input)
 
         def first_small(input):
-            small_step.remainder_events = input
+            small_step.remainder_events.extend(input)
 
         self.set_input = {
             InputEventLifeline.WHOLE: whole,
@@ -90,13 +91,13 @@ class StatechartInstance(Instance):
             InputEventLifeline.FIRST_SMALL_STEP: first_small
         }[semantics.input_event_lifeline]
 
-        raise_nextbs = lambda e, time_offset: self.execution.output.append(OutputEvent(e, InstancesTarget([self]), time_offset))
 
+        self.self_list = [self]
+        
         raise_internal = {
-            InternalEventLifeline.QUEUE: lambda e: raise_nextbs(e, 0),
+            InternalEventLifeline.QUEUE: lambda e: schedule_callback(0, e, self.self_list),
             InternalEventLifeline.NEXT_COMBO_STEP: combo_step.add_next_event,
             InternalEventLifeline.NEXT_SMALL_STEP: small_step.add_next_event,
-
             InternalEventLifeline.REMAINDER: self._big_step.add_remainder_event,
             InternalEventLifeline.SAME: small_step.add_remainder_event,
         }[semantics.internal_event_lifeline]
@@ -130,17 +131,20 @@ class StatechartInstance(Instance):
         self.execution.gc_memory = gc_memory
         self.execution.rhs_memory = rhs_memory
         self.execution.raise_internal = raise_internal
-        self.execution.raise_next_bs = raise_nextbs
+        self.execution.schedule_callback = schedule_callback
+        self.execution.cancel_callback = cancel_callback
+        self.execution.raise_output = output_callback
 
 
     # enter default states, generating a set of output events
-    def initialize(self) -> List[OutputEvent]:
+    def initialize(self):
         self.execution.initialize()
-        return self.execution.collect_output()
+        self.output_callback(OutputEvent(port="trace", name="big_step_completed", params=self.self_list))
 
     # perform a big step. generating a set of output events
-    def big_step(self, input_events: List[Event]) -> List[OutputEvent]:
+    def big_step(self, input_events: List[InternalEvent]):
         # print_debug('attempting big step, input_events='+str(input_events))
         self.set_input(input_events)
         self._big_step.run_and_cycle_events()
-        return self.execution.collect_output()
+
+        self.output_callback(OutputEvent(port="trace", name="big_step_completed", params=self.self_list))

+ 1 - 1
src/sccd/statechart/parser/xml.py

@@ -109,7 +109,7 @@ def statechart_parser_rules(globals, path, load_external = True, parse_f = parse
               # output event - no ID in global namespace
               statechart.event_outport[event_name] = port
               globals.outports.assign_id(port)
-              return RaiseOutputEvent(name=event_name, params=params, outport=port, time_offset=duration(0))
+              return RaiseOutputEvent(name=event_name, params=params, outport=port)
           return ([("param*", parse_param)], finish_raise)
 
         def parse_code(el):

+ 7 - 8
src/sccd/statechart/static/action.py

@@ -19,7 +19,9 @@ class SCDurationLiteral(DurationLiteral):
 
 @dataclass
 class EvalContext:
-    current_state: 'StatechartState'
+    __slots__ = ["execution", "events", "memory"]
+    
+    execution: 'StatechartExecution'
     events: List['Event']
     memory: 'MemoryInterface'
 
@@ -51,20 +53,17 @@ class RaiseInternalEvent(RaiseEvent):
 
     def exec(self, ctx: EvalContext):
         params = self._eval_params(ctx.memory)
-        ctx.current_state.raise_internal(
-            Event(id=self.event_id, name=self.name, port="", params=params))
+        ctx.execution.raise_internal(
+            InternalEvent(id=self.event_id, name=self.name, params=params))
 
 @dataclass
 class RaiseOutputEvent(RaiseEvent):
     outport: str
-    time_offset: int
 
     def exec(self, ctx: EvalContext):
         params = self._eval_params(ctx.memory)
-        ctx.current_state.output.append(
-            OutputEvent(Event(id=0, name=self.name, port=self.outport, params=params),
-                    OutputPortTarget(self.outport),
-                    self.time_offset))
+        ctx.execution.raise_output(
+            OutputEvent(port=self.outport, name=self.name, params=params))
 
     def render(self) -> str:
         return '^'+self.outport + '.' + self.name

+ 21 - 9
src/sccd/statechart/static/statechart.py

@@ -83,17 +83,29 @@ class SemanticConfiguration:
     return [SemanticConfiguration(**{f.name: o for f,o in zip(my_fields, variant)}) for variant in variants]
 
 @dataclass
-class Statechart:
+class Statechart(Freezable):
   __slots__ = ["semantics", "scope", "datamodel", "events", "internal_events", "inport_events", "event_outport", "tree"]
+
+  def __init__(self, semantics: SemanticConfiguration, scope: Scope, datamodel: Optional[Block], events: Bitmap, internal_events: Bitmap, inport_events: Dict[str, Set[int]], event_outport: Dict[str, str], tree: StateTree):
+    
+    super().__init__()
   
-  semantics: SemanticConfiguration
+    # Semantic configuration for statechart execution
+    self.semantics: SemanticConfiguration = semantics
+
+    # Instance scope, the set of variable names (and their types and offsets in memory) that belong to the statechart
+    self.scope: Scope = scope
 
-  scope: Scope
-  datamodel: Optional[Block] # block of statements setting up the datamodel (variables in instance scope)
+    # Block of statements setting up the datamodel (variables in instance scope)
+    self.datamodel: Optional[Block] = datamodel
 
-  events: Bitmap # union of all transition trigger's enabling sets
-  internal_events: Bitmap
-  inport_events: Dict[str, Set[int]] # mapping from inport to set of event IDs
-  event_outport: Dict[str, str] # mapping from event name to outport
+    # The union of all positive event triggers in the statechart.
+    self.events: Bitmap = events
+    # All internally raised events in the statechart, may overlap with input events.
+    self.internal_events: Bitmap = internal_events
+    # Mapping from inport to set of event IDs
+    self.inport_events: Dict[str, Set[int]] = inport_events
+    # Mapping from event name to outport
+    self.event_outport: Dict[str, str] = event_outport
 
-  tree: StateTree
+    self.tree: StateTree = tree

+ 18 - 16
src/sccd/test/run.py

@@ -42,16 +42,28 @@ class Test(unittest.TestCase):
       print_debug('\n'+test.name)
       pipe = QueueImplementation()
 
-      controller = Controller(test.cd)
+      current_big_step = []
+      def on_output(event: OutputEvent):
+        nonlocal current_big_step
+        if event.port == "trace":
+          if event.name == "big_step_completed":
+            if len(current_big_step) > 0:
+              pipe.put(current_big_step)
+            current_big_step = []
+        else:
+          current_big_step.append(event)
+
+
+      controller = Controller(test.cd, on_output)
 
       for i in test.input:
-        controller.add_input(i.event, i.at.eval(None))
+        controller._schedule(i.timestamp.eval(None), i.event, controller._inport_to_instances(i.port))
 
       def controller_thread():
         try:
           # Run as-fast-as-possible, always advancing time to the next item in event queue, no sleeping.
           # The call returns when the event queue is empty and therefore the simulation is finished.
-          controller.run_until(None, pipe)
+          controller.run_until(None)
         except Exception as e:
           print_debug(e)
           pipe.put(e, block=True, timeout=None)
@@ -81,23 +93,13 @@ class Test(unittest.TestCase):
 
         elif data is None:
           # End of output
-          if len(actual) < len(expected):
-            fail("Less output than expected.")
-          else:
-            break
+          break
 
         else:
-          big_step_index = len(actual)
           actual.append(data)
 
-          if len(actual) > len(expected):
-            fail("More output than expected.")
-
-          actual_big_step = actual[big_step_index]
-          expected_big_step = expected[big_step_index]
-
-          if actual_big_step != expected_big_step:
-            fail("Big step %d: output differs." % big_step_index)
+      if actual != expected:
+        fail("Output differs from expected.")
 
 
 class FailingTest(Test):

+ 10 - 6
src/sccd/test/xml.py

@@ -1,21 +1,22 @@
 from sccd.statechart.parser.xml import *
 from sccd.cd.globals import *
-from sccd.statechart.dynamic.event import Event
+from sccd.statechart.dynamic.event import InternalEvent
 from sccd.cd.cd import *
 
 _empty_scope = Scope("test", parent=None)
 
 @dataclass
 class TestInputEvent:
-  event: Event
-  at: Expression
+  event: InternalEvent
+  port: str
+  timestamp: Expression
 
 @dataclass
 class TestVariant:
   name: str
   cd: AbstractCD
   input: List[TestInputEvent]
-  output: List[List[Event]]
+  output: List[List[OutputEvent]]
 
 def test_parser_rules(statechart_parser_rules):
   globals = Globals()
@@ -32,8 +33,11 @@ def test_parser_rules(statechart_parser_rules):
         time_type = time_expr.init_expr(scope=_empty_scope)
         check_duration_type(time_type)
         params = []
+        event_id = globals.events.get_id(name)
         input.append(TestInputEvent(
-          event=Event(id=-1, name=name, port=port, params=params), at=time_expr))
+          event=InternalEvent(id=event_id, name=name, params=params),
+          port=port,
+          timestamp=time_expr))
 
         def parse_param(el):
           text = require_attribute(el, "expr")
@@ -54,7 +58,7 @@ def test_parser_rules(statechart_parser_rules):
           name = require_attribute(el, "name")
           port = require_attribute(el, "port")
           params = []
-          big_step.append(Event(id=0, name=name, port=port, params=params))
+          big_step.append(OutputEvent(name=name, port=port, params=params))
 
           def parse_param(el):
             val_text = require_attribute(el, "val")

+ 2 - 1
src/sccd/util/namespace.py

@@ -1,12 +1,13 @@
 from typing import *
 
+# Assigns unique integer IDs (counting from 0) to names.
 class Namespace:
   def __init__(self):
     self.ids: Dict[str, int] = {}
     self.names: List[str] = []
 
   def assign_id(self, name: str) -> int:
-    id = self.ids.setdefault(name, len(self.ids))
+    id = self.ids.setdefault(name, len(self.names))
     if id == len(self.names):
         self.names.append(name)
     return id