Переглянути джерело

Factor out reusable event loop integration stuff from Digital Watch example to SCCD library. Rename "Model" classes to "CD" (Class Diagram) for clarity.

Joeri Exelmans 5 роки тому
батько
коміт
eb867dae7f

+ 1 - 1
examples/digitalwatch/DigitalWatchGUI.py

@@ -10,7 +10,6 @@ from time import localtime
 from tkinter import Frame, PhotoImage, Canvas
 from tkinter.constants import BOTH
 
-
 CANVAS_W = 222
 CANVAS_H = 236
 
@@ -548,6 +547,7 @@ class DigitalWatchGUI_Static(Frame):
                                                         font=FONT_TIME,
                                                         justify="center",
                                                         text=chronoToDraw)
+
     def hideChrono(self):
         if self.chronoTag != None:
             self.displayCanvas.delete(self.chronoTag)

+ 23 - 50
examples/digitalwatch/run.py

@@ -1,30 +1,27 @@
-from DigitalWatchGUI import DigitalWatchGUI
-import tkinter
-from tkinter.constants import NO
-from time import perf_counter
-from sccd.controller.controller import *
 from sccd.statechart.parser.xml import parse_f, statechart_parser_rules
-from sccd.model.model import *
+from sccd.cd.cd import *
+from sccd.controller.eventloop import *
 import queue
 
-def now():
-    return int(perf_counter()*10000) # 100 us-precision, same as our model delta
+MODEL_DELTA = duration(100, Microsecond)
 
 def main():
+    # Load statechart
     g = Globals()
     sc_rules = statechart_parser_rules(g, "statechart_digitalwatch.xml")
     statechart = parse_f("statechart_digitalwatch.xml", rules=sc_rules)
-    model = SingleInstanceModel(g, statechart)
-    g.set_delta(duration(100, Microsecond))
-    controller = Controller(model)
+    cd = SingleInstanceCD(g, statechart)
+    g.set_delta(MODEL_DELTA)
 
-    scheduled = None
+    eventloop = None
 
-    def gui_event(event: str):
-        # print("in:", event)
-        if scheduled:
-            tk.after_cancel(scheduled)
-        wakeup(event)
+    def on_gui_event(event: str):
+        eventloop.add_input(InputEvent(name=event, port="in", params=[], time_offset=duration(0)))
+        eventloop.interrupt()
+
+    import tkinter
+    from tkinter.constants import NO
+    from DigitalWatchGUI import DigitalWatchGUI
 
     tk = tkinter.Tk()
     tk.withdraw()
@@ -32,41 +29,17 @@ def main():
     topLevel.resizable(width=NO, height=NO)
     topLevel.title("DWatch")
     gui = DigitalWatchGUI(topLevel)
-    gui.controller.send_event = gui_event
-
-    q = queue.Queue()
-    start_time = now()
-
-    def wakeup(event: Optional[str] = None):
-        nonlocal scheduled
-
-        # run controller - output will accumulate in 'q'
-        controller.run_until(now() - start_time, q)
-
-        # controller's "simulated time" is now "now", so "now" is the time to add input:
-        if event is not None:
-            controller.add_input(InputEvent(name=event, port="in", params=[], time_offset=duration(0)))
-            controller.run_until(now() - start_time, q)
-
-        # process output
-        try:
-            while True:
-                big_step_output = q.get_nowait()
-                for e in big_step_output:
-                    # print("out:", e.name)
-                    # the output event names happen to be functions on our GUI controller:
-                    method = getattr(gui.controller, e.name)
-                    method()
-        except queue.Empty:
-            pass
+    gui.controller.send_event = on_gui_event
 
-        # go to sleep
-        # convert our statechart's timestamp to tkinter's (100 us -> 1 ms)
-        sleep_duration = (controller.next_wakeup() - controller.simulated_time) // 10
-        scheduled = tk.after(sleep_duration, wakeup)
-        # print("sleeping %d ms" % sleep_duration)
+    def on_big_step(output):
+        for e in output:
+            # print("out:", e.name)
+            # the output event names happen to be functions on our GUI controller:
+            method = getattr(gui.controller, e.name)
+            method()
 
-    tk.after(0, wakeup)
+    eventloop = EventLoop(cd, TkInterImplementation(tk), on_big_step)
+    eventloop.start()
     tk.mainloop()
 
 if __name__ == '__main__':

+ 3 - 1
examples/digitalwatch/statechart_digitalwatch.xml

@@ -124,7 +124,9 @@
 
         <state id="ChronoUpdate">
           <transition event="topLeftPressed" target="../TimeUpdate"/>
-          <transition after="10 ms" target=".">
+          <!-- the rendering of the display takes a lot of CPU time (4.25 ms on my machine)
+               so we don't re-render every 10 ms.. looks good enough -->
+          <transition after="30 ms" target=".">
             <raise event="refreshChronoDisplay"/>
           </transition>
         </state>

src/sccd/model/__init__.py → src/sccd/cd/__init__.py


+ 4 - 4
src/sccd/model/model.py

@@ -2,10 +2,10 @@ from abc import *
 from dataclasses import *
 from typing import *
 from sccd.statechart.static.statechart import *
-from sccd.model.globals import *
+from sccd.cd.globals import *
 
 @dataclass
-class AbstractModel(ABC):
+class AbstractCD(ABC):
   globals: Globals
 
   @abstractmethod
@@ -13,7 +13,7 @@ class AbstractModel(ABC):
     pass
 
 @dataclass
-class MultiInstanceModel(AbstractModel):
+class CD(AbstractCD):
   classes: Dict[str, Statechart]
   default_class: Optional[str]
 
@@ -21,7 +21,7 @@ class MultiInstanceModel(AbstractModel):
     return self.classes[self.default_class]
 
 @dataclass
-class SingleInstanceModel(AbstractModel):
+class SingleInstanceCD(AbstractCD):
   statechart: Statechart
 
   def get_default_class(self) -> Statechart:

src/sccd/model/globals.py → src/sccd/cd/globals.py


+ 12 - 12
src/sccd/controller/controller.py

@@ -5,7 +5,7 @@ from sccd.controller.event_queue import *
 from sccd.statechart.dynamic.event import *
 from sccd.controller.object_manager import *
 from sccd.util.debug import print_debug
-from sccd.model.model import *
+from sccd.cd.cd import *
 
 @dataclasses.dataclass
 class InputEvent:
@@ -26,36 +26,36 @@ class Controller:
         event: Event
         targets: List[Instance]
 
-    def __init__(self, model: AbstractModel):
-        self.model = model
-        self.object_manager = ObjectManager(model)
+    def __init__(self, cd: AbstractCD):
+        self.cd = cd
+        self.object_manager = ObjectManager(cd)
         self.queue: EventQueue[Timestamp, EventQueueEntry] = EventQueue()
 
         self.simulated_time = 0 # integer
         self.initialized = False
 
-        self.model.globals.assert_ready()
-        # print_debug("model delta is %s" % str(self.model.globals.delta))
+        self.cd.globals.assert_ready()
+        # print_debug("model delta is %s" % str(self.cd.globals.delta))
 
         # First call to 'run_until' method initializes
         self.run_until = self._initialize
 
     def _duration_to_time_offset(self, d: Duration) -> int:
-        if self.model.globals.delta == duration(0):
+        if self.cd.globals.delta == duration(0):
             return 0
-        return d // self.model.globals.delta
+        return d // self.cd.globals.delta
 
     def add_input(self, input: InputEvent):
             if input.name == "":
                 raise Exception("Input event can't have an empty name.")
         
             # try:
-            #     self.model.globals.inports.get_id(input.port)
+            #     self.cd.globals.inports.get_id(input.port)
             # except KeyError as e:
             #     raise Exception("No such port: '%s'" % input.port) from e
 
             try:
-                event_id = self.model.globals.events.get_id(input.name)
+                event_id = self.cd.globals.events.get_id(input.name)
             except KeyError as e:
                 raise Exception("No such event: '%s'" % input.name) from e
 
@@ -79,7 +79,7 @@ class Controller:
 
     # Returns duration since start
     def get_simulated_duration(self) -> Duration:
-        return (self.model.globals.delta * self.simulated_time)
+        return (self.cd.globals.delta * self.simulated_time)
 
     # 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):
@@ -124,7 +124,7 @@ class Controller:
             # run all instances for whom there are events
             for instance in entry.targets:
                 output = instance.big_step([entry.event])
-                # print_debug("completed big step (time = %s)" % str(self.model.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

+ 83 - 0
src/sccd/controller/eventloop.py

@@ -0,0 +1,83 @@
+from sccd.controller.realtime import *
+import tkinter
+
+ScheduledID = Any
+
+@dataclass
+class EventLoopImplementation(ABC):
+    @abstractmethod
+    def time_unit(self) -> Duration:
+        pass
+
+    @abstractmethod
+    def schedule(self) -> Callable[[int, Callable[[],None]], ScheduledID]:
+        pass
+
+    @abstractmethod
+    def cancel(self) -> Callable[[ScheduledID], None]:
+        pass
+
+@dataclass
+class TkInterImplementation(EventLoopImplementation):
+    tk: tkinter.Tk
+
+    def time_unit(self) -> Duration:
+        return duration(1, Millisecond)
+
+    def schedule(self) -> Callable[[int, Callable[[],None]], ScheduledID]:
+        return self.tk.after
+
+    def cancel(self) -> Callable[[ScheduledID], None]:
+        return self.tk.after_cancel
+
+class EventLoop:
+    def __init__(self, cd, event_loop, output_callback, time_impl=DefaultTimeImplementation):
+        self.timer = Timer(time_impl, unit=cd.globals.delta) # will give us timestamps in model unit
+        self.controller = Controller(cd)
+        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
+
+        self.scheduled = None
+        self.queue = queue.Queue()
+
+    def _wakeup(self):
+        # run controller - output will accumulate in queue
+        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
+
+        # go to sleep
+        # convert our statechart's timestamp to tkinter's (100 us -> 1 ms)
+        sleep_duration = self.event_loop_convert(
+            self.controller.next_wakeup() - self.controller.simulated_time)
+        self.scheduled = self.event_loop.schedule()(sleep_duration, self._wakeup)
+        # print("sleeping %d ms" % sleep_duration)
+
+    def start(self):
+        self.timer.start()
+        self._wakeup()
+
+    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):
+        offset = self.controller.simulated_time - self.timer.now()
+        event.time_offset += offset * self.timer.unit
+        self.controller.add_input(event)
+
+    # Do NOT call while paused!
+    def interrupt(self):
+        self.event_loop.cancel()(self.scheduled)
+        self._wakeup()

+ 4 - 4
src/sccd/controller/object_manager.py

@@ -7,20 +7,20 @@ from sccd.statechart.dynamic.statechart_instance import *
 class ObjectManager(Instance):
     _regex_pattern = re.compile("^([a-zA-Z_]\w*)(?:\[(\d+)\])?$")
 
-    def __init__(self, model):
-        self.model = model
+    def __init__(self, cd):
+        self.cd = cd
 
         # 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(model.get_default_class(), self)
+        i = StatechartInstance(cd.get_default_class(), self)
         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.model.classes[class_name]
+        statechart = self.cd.classes[class_name]
         i = StatechartInstance(statechart, self)
         self.instances.append(i)
         return i

+ 52 - 0
src/sccd/controller/realtime.py

@@ -0,0 +1,52 @@
+from abc import *
+from sccd.controller.controller import *
+from numbers import Real # superclass for 'int' and 'float'
+
+# The only requirement for a TimeImplementation is that the diffs between get_time call results are real durations of a non-complex number type (int, float).
+@dataclass
+class TimeImplementation:
+    time_unit: Duration
+    get_time: Callable[[], Real]
+
+# This is the most accurate time function in Python 3.6
+from time import perf_counter
+PerfCounterTime = TimeImplementation(
+    time_unit=duration(1, Second),
+    get_time=perf_counter) # returns float
+
+DefaultTimeImplementation = PerfCounterTime
+
+# Python >= 3.7 has a better time function
+import sys
+if sys.version_info.minor >= 7:
+    from time import perf_counter_ns
+    PerfCounterNSTime = TimeImplementation(
+        time_unit=duration(1, Nanosecond),
+        get_time=perf_counter_ns) # returns int
+    DefaultTimeImplementation = PerfCounterNSTime
+
+
+class Timer:
+    def __init__(self, impl: TimeImplementation, unit: Duration):
+        self.impl = impl
+        self.unit = unit
+        self.paused_at = 0
+        self.started_at = None
+        self.convert = lambda x: int(get_conversion_f(
+            from_unit=self.impl.time_unit, to_unit=unit)(x))
+        self.paused = True
+
+    def start(self):
+        self.started_at = self.convert(self.impl.get_time()) + self.paused_at
+        self.paused = False
+
+    def pause(self):
+        self.paused_at = self.now()
+        self.paused = True
+
+    # Only call when not paused!
+    def now(self) -> int:
+        return self.convert(self.impl.get_time()) - self.started_at
+
+    def is_paused(self):
+        return self.paused

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

@@ -2,7 +2,7 @@ import os
 from lark import Lark
 from sccd.action_lang.parser import text as action_lang
 from sccd.statechart.static.tree import *
-from sccd.model.globals import *
+from sccd.cd.globals import *
 
 _grammar_dir = os.path.dirname(__file__)
 

+ 3 - 3
src/sccd/test/parser.py

@@ -1,8 +1,8 @@
 from sccd.statechart.parser.xml import *
-from sccd.model.globals import *
+from sccd.cd.globals import *
 from sccd.controller.controller import InputEvent
 from sccd.statechart.dynamic.event import Event
-from sccd.model.model import *
+from sccd.cd.cd import *
 
 @dataclass
 class TestVariant:
@@ -68,7 +68,7 @@ def test_parser_rules(statechart_parser_rules):
 
       return [TestVariant(
         name=variant_description(i, variant),
-        model=SingleInstanceModel(
+        model=SingleInstanceCD(
           globals,
           Statechart(
             semantics=variant,

+ 1 - 1
src/sccd/test/run.py

@@ -5,7 +5,7 @@ import queue
 import functools
 from sccd.util.os_tools import *
 from sccd.util.debug import *
-from sccd.model.model import *
+from sccd.cd.cd import *
 from sccd.controller.controller import *
 from sccd.test.parser import *
 from sccd.util import timer

+ 23 - 2
src/sccd/util/duration.py

@@ -55,6 +55,10 @@ class Duration(ABC):
   def __eq__(self):
     pass
 
+  @abstractmethod
+  def __add__(self):
+    pass
+
   @abstractmethod
   def __mul__(self):
     pass
@@ -87,6 +91,9 @@ class _ZeroDuration(Duration):
   def __eq__(self, other):
     return self is other
 
+  def __add__(self, other):
+    return other
+
   def __mul__(self, other: int) -> Duration:
     return self
 
@@ -131,12 +138,17 @@ class _NonZeroDuration(Duration):
   def __str__(self):
     return str(self.val)+' '+self.unit.notation
 
-
   def __eq__(self, other):
-    if isinstance(other, _ZeroDuration):
+    if other is _zero:
       return False
     return self.val == other.val and self.unit is other.unit
 
+  def __add__(self, other):
+    if other is _zero:
+      return self
+    self_val, other_val, unit = _same_unit(self, other)
+    return duration(self_val + other_val, unit)
+
   def __mul__(self, other: int) -> Duration:
     if other == 0:
       return _zero
@@ -167,3 +179,12 @@ def gcd_pair(x: Duration, y: Duration) -> Duration:
 
 def gcd(*iterable: Iterable[Duration]) -> Duration:
   return functools.reduce(gcd_pair, iterable, _zero)
+
+# Useful for efficiently converting many values from the same unit to another same unit.
+def get_conversion_f(from_unit: Duration, to_unit: Duration):
+  if from_unit > to_unit:
+    factor = from_unit // to_unit
+    return lambda x: x * factor
+  else:
+    factor = to_unit // from_unit
+    return lambda x: x // factor