Selaa lähdekoodia

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 vuotta sitten
vanhempi
commit
03957f99e5

+ 7 - 5
examples/digitalwatch/run.py

@@ -18,19 +18,21 @@ def main():
     gui = DigitalWatchGUI(topLevel)
     gui = DigitalWatchGUI(topLevel)
 
 
     def on_gui_event(event: str):
     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()
         eventloop.interrupt()
 
 
     gui.controller.send_event = on_gui_event
     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)
             # print("out:", e.name)
             # the output event names happen to be functions on our GUI controller:
             # 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()
             method()
 
 
-    eventloop = EventLoop(cd, TkInterImplementation(tk), on_big_step)
+    controller = Controller(cd, on_output)
+    eventloop = EventLoop(controller, TkInterImplementation(tk))
 
 
     eventloop.start()
     eventloop.start()
     tk.mainloop()
     tk.mainloop()

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

@@ -1,4 +1,3 @@
-import sys
 import readline
 import readline
 import termcolor
 import termcolor
 from sccd.action_lang.dynamic.memory import *
 from sccd.action_lang.dynamic.memory import *
@@ -35,6 +34,6 @@ if __name__ == "__main__":
       print(e)
       print(e)
     except (LarkError, ModelError, SCCDRuntimeException) as e:
     except (LarkError, ModelError, SCCDRuntimeException) as e:
       print(e)
       print(e)
-    except KeyboardInterrupt:
+    except (KeyboardInterrupt, EOFError):
       print()
       print()
       exit()
       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.duration import *
 from sccd.util.debug import *
 from sccd.util.debug import *
 
 
+# Global values for all statecharts in a class diagram.
 class Globals:
 class Globals:
   def __init__(self):
   def __init__(self):
+    # All the event names in the model
     self.events = Namespace()
     self.events = Namespace()
+
     self.inports = Namespace()
     self.inports = Namespace()
     self.outports = Namespace()
     self.outports = Namespace()
+
+    # All the duration literals occuring in action code expressions in the class diagram.
     self.durations: List[SCDurationLiteral] = []
     self.durations: List[SCDurationLiteral] = []
 
 
     # The smallest unit for all durations in the model.
     # The smallest unit for all durations in the model.
@@ -21,14 +26,14 @@ class Globals:
     # Ensure delta not too big
     # Ensure delta not too big
     if delta:
     if delta:
       if duration(0) < gcd_delta < 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:
       else:
         self.delta = delta
         self.delta = delta
     else:
     else:
       self.delta = gcd_delta
       self.delta = gcd_delta
 
 
     if self.delta != duration(0):
     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:
       for d in self.durations:
         d.opt = d.d // self.delta
         d.opt = d.d // self.delta
     else:
     else:
@@ -36,5 +41,4 @@ class Globals:
         d.opt = 0
         d.opt = 0
 
 
   def assert_ready(self):
   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.util.debug import print_debug
 from sccd.cd.cd import *
 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:
 # 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, ...
 # Threads, integration with existing event loop, game loop, test framework, ...
 # The Controller class itself is NOT thread-safe.
 # The Controller class itself is NOT thread-safe.
@@ -16,17 +19,27 @@ class Controller:
     @dataclasses.dataclass(eq=False, frozen=True)
     @dataclasses.dataclass(eq=False, frozen=True)
     class EventQueueEntry:
     class EventQueueEntry:
         __slots__ = ["event", "targets"]
         __slots__ = ["event", "targets"]
-        event: Event
+        event: InternalEvent
         targets: List[Instance]
         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.cd = cd
-        self.object_manager = ObjectManager(cd)
-        self.queue: EventQueue[int, EventQueueEntry] = EventQueue()
 
 
         self.simulated_time = 0 # integer
         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:
         if DEBUG:
             self.cd.print()
             self.cd.print()
@@ -36,86 +49,66 @@ class Controller:
         self.run_until = self._run_until_w_initialize
         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
     # Get timestamp of next entry in event queue
     def next_wakeup(self) -> Optional[int]:
     def next_wakeup(self) -> Optional[int]:
         return self.queue.earliest_timestamp()
         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...
         # first run...
         # initialize the object manager, in turn initializing our default class
         # initialize the object manager, in turn initializing our default class
         # and adding the generated events to the queue
         # and adding the generated events to the queue
         for i in self.object_manager.instances:
         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'
         # Next call to 'run_until' will call '_run_until'
         self.run_until = self._run_until
         self.run_until = self._run_until
 
 
         # Let's try it out :)
         # 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.
     # 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.
     # 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"
         # Actual "event loop"
         for timestamp, entry in self.queue.due(now):
         for timestamp, entry in self.queue.due(now):
             if timestamp != self.simulated_time:
             if timestamp != self.simulated_time:
                 # make time leap
                 # make time leap
                 self.simulated_time = timestamp
                 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
             # run all instances for whom there are events
             for instance in entry.targets:
             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))
                 # 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
         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
 from  sccd.util import timer
 
 
 Timestamp = TypeVar('Timestamp')
 Timestamp = TypeVar('Timestamp')
-
 Item = TypeVar('Item')
 Item = TypeVar('Item')
 
 
 class EventQueue(Generic[Timestamp, Item]):
 class EventQueue(Generic[Timestamp, Item]):
@@ -36,15 +35,15 @@ class EventQueue(Generic[Timestamp, Item]):
             self.counters[timestamp] = self.counters.setdefault(timestamp, 0) + 1
             self.counters[timestamp] = self.counters.setdefault(timestamp, 0) + 1
             def_event = (timestamp, self.counters[timestamp], item)
             def_event = (timestamp, self.counters[timestamp], item)
             heappush(self.queue, def_event)
             heappush(self.queue, def_event)
-            return def_event
     
     
     def remove(self, item: Item):
     def remove(self, item: Item):
         with timer.Context("event_queue"):
         with timer.Context("event_queue"):
             self.removed.add(item)
             self.removed.add(item)
             if len(self.removed) > 100:
             if len(self.removed) > 100:
                 self.queue = [x for x in self.queue if x not in self.removed]
                 self.queue = [x for x in self.queue if x not in self.removed]
+                heapify(self.queue)
                 self.removed = set()
                 self.removed = set()
-    
+
     # Raises exception if called on empty queue
     # Raises exception if called on empty queue
     def pop(self) -> Tuple[Timestamp, Item]:
     def pop(self) -> Tuple[Timestamp, Item]:
         with timer.Context("event_queue"):
         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]:
     def due(self, timestamp: Optional[Timestamp]) -> Generator[Tuple[Timestamp, Item], None, None]:
         while self.is_due(timestamp):
         while self.is_due(timestamp):
             yield self.pop()
             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 re
 import abc
 import abc
 from typing import List, Tuple
 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.
 # TODO: Clean this mess up. Look at all object management operations and see how they can be improved.
 class ObjectManager(Instance):
 class ObjectManager(Instance):
+    __slots__ = ["cd", "output_callback", "schedule_callback", "instances"]
+
     _regex_pattern = re.compile("^([a-zA-Z_]\w*)(?:\[(\d+)\])?$")
     _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.cd = cd
+        self.output_callback = output_callback
+        self.schedule_callback = schedule_callback
+        self.cancel_callback = cancel_callback
 
 
         # set of all instances in the runtime
         # set of all instances in the runtime
         # we need to maintain this set in order to do broadcasts
         # we need to maintain this set in order to do broadcasts
         self.instances = [self] # object manager is an instance too!
         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)
         self.instances.append(i)
 
 
     def _create(self, class_name) -> StatechartInstance:
     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)
         self.instances.append(i)
         return i
         return i
 
 
-    def initialize(self) -> List[OutputEvent]:
-        return []
+    def initialize(self):
+        pass
 
 
     # Implementation of super class: Instance
     # 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]]:
     # def _assoc_ref(self, input_string) -> List[Tuple[str,int]]:
     #     if len(input_string) == 0:
     #     if len(input_string) == 0:
@@ -59,10 +63,10 @@ class ObjectManager(Instance):
     #             raise AssociationReferenceException("Invalid entry in association reference. Input string: " + input_string)
     #             raise AssociationReferenceException("Invalid entry in association reference. Input string: " + input_string)
     #     return result
     #     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]:
     # def _handle_create(self, timestamp, parameters) -> List[OutputEvent]:
     #     if len(parameters) < 2:
     #     if len(parameters) < 2:
@@ -229,11 +233,11 @@ class ObjectManager(Instance):
     #         currents = nexts
     #         currents = nexts
     #     return currents
     #     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:
 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.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.scheduled = None
-        self.queue = queue.Queue()
 
 
     def _wakeup(self):
     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
         # back to sleep
         now = self.timer.now()
         now = self.timer.now()
@@ -58,19 +51,15 @@ class EventLoop:
         self.timer.start()
         self.timer.start()
         self._wakeup()
         self._wakeup()
 
 
+    def now(self):
+        return self.timer.now()
+
     # Uncomment if ever needed:
     # Uncomment if ever needed:
     # Does not mix well with interrupt().
     # Does not mix well with interrupt().
     # def pause(self):
     # def pause(self):
     #     self.timer.pause()
     #     self.timer.pause()
     #     self.event_loop.cancel()(self.scheduled)
     #     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):
     def interrupt(self):
         if self.scheduled:
         if self.scheduled:
             self.event_loop.cancel(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 typing import *
 from sccd.util.duration 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):
     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):
     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:
         if self.params:
             s += str(self.params)
             s += str(self.params)
         s += ")"
         s += ")"
         return termcolor.colored(s, 'yellow')
         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)
     cache: Dict[Tuple[Bitmap,Bitmap], List[Transition]] = field(default_factory=dict)
 
 
     @abstractmethod
     @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
         pass
 
 
 class CandidatesGeneratorCurrentConfigBased(CandidatesGenerator):
 class CandidatesGeneratorCurrentConfigBased(CandidatesGenerator):
@@ -40,7 +40,7 @@ class CandidatesGeneratorCurrentConfigBased(CandidatesGenerator):
             candidates.reverse()
             candidates.reverse()
         return candidates
         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)
         events_bitmap = Bitmap.from_list(e.id for e in enabled_events)
         key = (state.configuration, forbidden_arenas)
         key = (state.configuration, forbidden_arenas)
 
 
@@ -72,7 +72,7 @@ class CandidatesGeneratorEventBased(CandidatesGenerator):
             candidates.reverse()
             candidates.reverse()
         return candidates
         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)
         events_bitmap = bm_from_list(e.id for e in enabled_events)
         key = (events_bitmap, forbidden_arenas)
         key = (events_bitmap, forbidden_arenas)
 
 
@@ -121,13 +121,13 @@ class Round(ABC):
     def _run(self, forbidden_arenas: Bitmap) -> RoundResult:
     def _run(self, forbidden_arenas: Bitmap) -> RoundResult:
         pass
         pass
 
 
-    def add_remainder_event(self, event: Event):
+    def add_remainder_event(self, event: InternalEvent):
         self.remainder_events.append(event)
         self.remainder_events.append(event)
 
 
-    def add_next_event(self, event: Event):
+    def add_next_event(self, event: InternalEvent):
         self.next_events.append(event)
         self.next_events.append(event)
 
 
-    def enabled_events(self) -> List[Event]:
+    def enabled_events(self) -> List[InternalEvent]:
         if self.parent:
         if self.parent:
             return self.remainder_events + self.parent.enabled_events()
             return self.remainder_events + self.parent.enabled_events()
         else:
         else:

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

@@ -10,13 +10,14 @@ from sccd.util import timer
 # Set of current states etc.
 # Set of current states etc.
 class StatechartExecution:
 class StatechartExecution:
 
 
-    def __init__(self, statechart: Statechart):
+    def __init__(self, instance, statechart: Statechart):
+        self.instance = instance
         self.statechart = statechart
         self.statechart = statechart
 
 
         self.gc_memory = None
         self.gc_memory = None
         self.rhs_memory = None
         self.rhs_memory = None
         self.raise_internal = None
         self.raise_internal = None
-        self.raise_next_bs = None
+        self.raise_output = None
 
 
         # set of current states
         # set of current states
         self.configuration: Bitmap = Bitmap()
         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.
         # 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]
         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
     # enter default states
     def initialize(self):
     def initialize(self):
         states = self.statechart.tree.root.opt.ts_static
         states = self.statechart.tree.root.opt.ts_static
         self.configuration = states
         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:
         if self.statechart.datamodel is not None:
             self.statechart.datamodel.exec(self.rhs_memory)
             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)
         return (self.statechart.tree.state_list[id] for id in id_iter)
 
 
     # events: list SORTED by event id
     # 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:
         try:
             with timer.Context("transition"):
             with timer.Context("transition"):
                 # Sequence of exit states is the intersection between set of current states and the arena's descendants.
                 # 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_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))
                     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))
                 print_debug("fire " + str(t))
 
 
@@ -80,6 +76,7 @@ class StatechartExecution:
                         # remember which state(s) we were in if a history state is present
                         # remember which state(s) we were in if a history state is present
                         for h, mask in s.opt.history:
                         for h, mask in s.opt.history:
                             self.history_values[h.history_id] = exit_ids & mask
                             self.history_values[h.history_id] = exit_ids & mask
+                        self._cancel_timers(s.opt.after_triggers)
                         self._perform_actions(ctx, s.exit)
                         self._perform_actions(ctx, s.exit)
                         self.configuration &= ~s.opt.state_id_bitmap
                         self.configuration &= ~s.opt.state_id_bitmap
 
 
@@ -109,16 +106,10 @@ class StatechartExecution:
 
 
     def check_guard(self, t, events) -> bool:
     def check_guard(self, t, events) -> bool:
         try:
         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:
             if t.guard is None:
                 return True
                 return True
             else:
             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)
                 self.gc_memory.push_frame(t.scope)
                 # Guard conditions can also refer to event parameters
                 # Guard conditions can also refer to event parameters
                 if t.trigger:
                 if t.trigger:
@@ -141,13 +132,12 @@ class StatechartExecution:
     def _start_timers(self, triggers: List[AfterTrigger]):
     def _start_timers(self, triggers: List[AfterTrigger]):
         for after in triggers:
         for after in triggers:
             delay: Duration = after.delay.eval(
             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.
     # Return whether the current configuration includes ALL the states given.
     def in_state(self, state_strings: List[str]) -> bool:
     def in_state(self, state_strings: List[str]) -> bool:
@@ -158,8 +148,3 @@ class StatechartExecution:
         # else:
         # else:
         #     print_debug("not in state"+str(state_strings))
         #     print_debug("not in state"+str(state_strings))
         return in_state
         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
 # Interface for all instances and also the Object Manager
 class Instance(ABC):
 class Instance(ABC):
     @abstractmethod
     @abstractmethod
-    def initialize(self) -> List[OutputEvent]:
+    def initialize(self):
         pass
         pass
 
 
     @abstractmethod
     @abstractmethod
-    def big_step(self, input_events: List[Event]) -> List[OutputEvent]:
+    def big_step(self, input_events: List[InternalEvent]):
         pass
         pass
 
 
 # Hardcoded limit on number of sub-rounds of combo and big step to detect never-ending superrounds.
 # 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
 LIMIT = 100
 
 
 class StatechartInstance(Instance):
 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
         semantics = statechart.semantics
 
 
@@ -76,13 +77,13 @@ class StatechartInstance(Instance):
         # Event lifeline semantics
         # Event lifeline semantics
 
 
         def whole(input):
         def whole(input):
-            self._big_step.remainder_events = input
+            self._big_step.remainder_events.extend(input)
 
 
         def first_combo(input):
         def first_combo(input):
-            combo_step.remainder_events = input
+            combo_step.remainder_events.extend(input)
 
 
         def first_small(input):
         def first_small(input):
-            small_step.remainder_events = input
+            small_step.remainder_events.extend(input)
 
 
         self.set_input = {
         self.set_input = {
             InputEventLifeline.WHOLE: whole,
             InputEventLifeline.WHOLE: whole,
@@ -90,13 +91,13 @@ class StatechartInstance(Instance):
             InputEventLifeline.FIRST_SMALL_STEP: first_small
             InputEventLifeline.FIRST_SMALL_STEP: first_small
         }[semantics.input_event_lifeline]
         }[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 = {
         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_COMBO_STEP: combo_step.add_next_event,
             InternalEventLifeline.NEXT_SMALL_STEP: small_step.add_next_event,
             InternalEventLifeline.NEXT_SMALL_STEP: small_step.add_next_event,
-
             InternalEventLifeline.REMAINDER: self._big_step.add_remainder_event,
             InternalEventLifeline.REMAINDER: self._big_step.add_remainder_event,
             InternalEventLifeline.SAME: small_step.add_remainder_event,
             InternalEventLifeline.SAME: small_step.add_remainder_event,
         }[semantics.internal_event_lifeline]
         }[semantics.internal_event_lifeline]
@@ -130,17 +131,20 @@ class StatechartInstance(Instance):
         self.execution.gc_memory = gc_memory
         self.execution.gc_memory = gc_memory
         self.execution.rhs_memory = rhs_memory
         self.execution.rhs_memory = rhs_memory
         self.execution.raise_internal = raise_internal
         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
     # enter default states, generating a set of output events
-    def initialize(self) -> List[OutputEvent]:
+    def initialize(self):
         self.execution.initialize()
         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
     # 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))
         # print_debug('attempting big step, input_events='+str(input_events))
         self.set_input(input_events)
         self.set_input(input_events)
         self._big_step.run_and_cycle_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
               # output event - no ID in global namespace
               statechart.event_outport[event_name] = port
               statechart.event_outport[event_name] = port
               globals.outports.assign_id(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)
           return ([("param*", parse_param)], finish_raise)
 
 
         def parse_code(el):
         def parse_code(el):

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

@@ -19,7 +19,9 @@ class SCDurationLiteral(DurationLiteral):
 
 
 @dataclass
 @dataclass
 class EvalContext:
 class EvalContext:
-    current_state: 'StatechartState'
+    __slots__ = ["execution", "events", "memory"]
+    
+    execution: 'StatechartExecution'
     events: List['Event']
     events: List['Event']
     memory: 'MemoryInterface'
     memory: 'MemoryInterface'
 
 
@@ -51,20 +53,17 @@ class RaiseInternalEvent(RaiseEvent):
 
 
     def exec(self, ctx: EvalContext):
     def exec(self, ctx: EvalContext):
         params = self._eval_params(ctx.memory)
         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
 @dataclass
 class RaiseOutputEvent(RaiseEvent):
 class RaiseOutputEvent(RaiseEvent):
     outport: str
     outport: str
-    time_offset: int
 
 
     def exec(self, ctx: EvalContext):
     def exec(self, ctx: EvalContext):
         params = self._eval_params(ctx.memory)
         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:
     def render(self) -> str:
         return '^'+self.outport + '.' + self.name
         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]
     return [SemanticConfiguration(**{f.name: o for f,o in zip(my_fields, variant)}) for variant in variants]
 
 
 @dataclass
 @dataclass
-class Statechart:
+class Statechart(Freezable):
   __slots__ = ["semantics", "scope", "datamodel", "events", "internal_events", "inport_events", "event_outport", "tree"]
   __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)
       print_debug('\n'+test.name)
       pipe = QueueImplementation()
       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:
       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():
       def controller_thread():
         try:
         try:
           # Run as-fast-as-possible, always advancing time to the next item in event queue, no sleeping.
           # 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.
           # 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:
         except Exception as e:
           print_debug(e)
           print_debug(e)
           pipe.put(e, block=True, timeout=None)
           pipe.put(e, block=True, timeout=None)
@@ -81,23 +93,13 @@ class Test(unittest.TestCase):
 
 
         elif data is None:
         elif data is None:
           # End of output
           # End of output
-          if len(actual) < len(expected):
-            fail("Less output than expected.")
-          else:
-            break
+          break
 
 
         else:
         else:
-          big_step_index = len(actual)
           actual.append(data)
           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):
 class FailingTest(Test):

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

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

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

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