This commit is contained in:
Joeri Exelmans 2024-11-28 23:47:19 +01:00
commit 8a24549cdb
21 changed files with 3478 additions and 0 deletions

View file

@ -0,0 +1,80 @@
# Author: Joeri Exelmans
class QueueEntry:
__slots__ = ('timestamp', 'raise_method', 'value', 'canceled', 'event_name') # For MAXIMUM performance :)
def __init__(self, timestamp, raise_method, value, event_name):
self.timestamp = timestamp
self.raise_method = raise_method
self.value = value
self.event_name = event_name # name of the event - only needed for debugging
self.canceled = False
def __repr__(self):
return f"({self.timestamp}, {self.event_name}, {self.value})"
# The main primitive for discrete event simulation.
# An event queue / event loop, using virtualized (simulated) time, independent of wall clock time.
class Controller:
def __init__(self):
self.event_queue = []
self.simulated_time = 0
self.input_tracers = []
# timestamp = absolute value, in simulated time (since beginning of simulation)
def add_input(self, sc, event_name, timestamp, value=None):
if '.' in event_name:
interface, short_event_name = event_name.split('.')
raise_method = getattr(getattr(sc, interface), 'raise_' + short_event_name)
else:
raise_method = getattr(sc, 'raise_' + event_name)
self.add_input_lowlevel(timestamp, raise_method, value, event_name)
# time_offset = relative to current simulated time
def add_input_relative(self, sc, event_name, time_offset=0, value=None):
timestamp = self.simulated_time + time_offset
return self.add_input(sc, event_name, timestamp, value)
def add_input_lowlevel(self, timestamp, raise_method, value, event_name):
e = QueueEntry(timestamp, raise_method, value, event_name)
self.event_queue.append(e)
# important to use a stable sorting algorithm here,
# so the order between equally-timestamped events is preserved:
self.event_queue.sort(key = lambda entry: entry.timestamp)
return e
# difference here is that the added event will occur BEFORE equally-timestamped events that were already in the queue
def add_input_lowlevel_interrupt(self, timestamp, raise_method, value, event_name):
e = QueueEntry(timestamp, raise_method, value, event_name)
self.event_queue.insert(0, e)
self.event_queue.sort(key = lambda entry: entry.timestamp)
return e
# Runs simulation as-fast-as-possible, until 'until'-timestamp (in simulated time)
# blocking, synchronous function
def run_until(self, until):
# print('running until', pretty_time(until))
while self.have_event() and self.get_earliest() <= until:
e = self.event_queue[0]
for sc, tracer in self.input_tracers:
if sc == e.raise_method.__self__:
tracer(e.timestamp, e.event_name, e.value)
# e = self.event_queue.pop();
self.event_queue = self.event_queue[1:]
if not e.canceled:
self.simulated_time = e.timestamp
if e.value == None:
e.raise_method()
else:
e.raise_method(e.value)
def have_event(self):
return len(self.event_queue) > 0
def get_earliest(self):
# return self.event_queue[-1].timestamp
return self.event_queue[0].timestamp
def pretty_time(time_ns):
return f'{round(time_ns / 1000000000, 3)} s'

View file

@ -0,0 +1,67 @@
from lib.controller import Controller
from lib.realtime.realtime import WallClock, AbstractRealTimeSimulation
import time
import abc
class AbstractEventLoop:
# delay in nanoseconds
# should be non-blocking
# should return timer ID
@abc.abstractmethod
def schedule(self, delay, callback):
pass
@abc.abstractmethod
def cancel(self, timer_id):
pass
# Runs virtual (simulated) time as close as possible to (scaled) wall-clock time.
# Depending on how fast your computer is, simulated time will always run a tiny bit behind wall-clock time, but this error will NOT grow over time.
class EventLoopRealTimeSimulation(AbstractRealTimeSimulation):
def __init__(self, controller: Controller, event_loop: AbstractEventLoop, wall_clock: WallClock, termination_condition=lambda: False, time_advance_callback=lambda simtime:None):
self.controller = controller
self.event_loop = event_loop
self.wall_clock = wall_clock
self.termination_condition = termination_condition
# Just a callback indicating that the current simulated time has changed.
# Can be useful for displaying the simulated time in a GUI or something
self.time_advance_callback = time_advance_callback
# At most one timer will be scheduled at the same time
self.scheduled_id = None
def poke(self):
if self.scheduled_id is not None:
self.event_loop.cancel(self.scheduled_id)
self.controller.run_until(self.wall_clock.time_since_start()) # this call may actually consume some time
self.time_advance_callback(self.controller.simulated_time)
if self.termination_condition():
print("Termination condition satisfied. Stop mainloop.")
return
if self.controller.have_event():
# schedule next wakeup
sleep_duration = self.wall_clock.sleep_duration_until(self.controller.get_earliest())
self.scheduled_id = self.event_loop.schedule(sleep_duration, self.poke)
# print("sleeping for", pretty_time(sleep_duration))
else:
# print("sleeping until woken up")
pass
# generate input event at the current wall clock time
# this method should be used for generating events that represent e.g., button clicks, key presses
def add_input_now(self, sc, event, value=None):
self.controller.add_input(sc, event, timestamp=self.wall_clock.time_since_start(), value=value)
self.poke()
# for events that need to happen immediately, at the current point in simulated time
def add_input_sync(self, sc, event, value=None):
self.controller.add_input_relative(sc, event, value=value)
self.poke()

View file

@ -0,0 +1,35 @@
import time
import abc
# Use time_scale different from 1.0 for scaled real-time execution:
# time_scale > 1 speeds up simulation
# 0 < time_scale < 1 slows down simulation
class WallClock:
def __init__(self, time_scale=1.0):
self.time_scale = time_scale
self.purposefully_behind = 0
def record_start_time(self):
self.start_time = time.perf_counter_ns()
def time_since_start(self):
time_since_start = time.perf_counter_ns() - self.start_time
return (time_since_start * self.time_scale) + self.purposefully_behind
def sleep_duration_until(self, earliest_event_time):
now = self.time_since_start()
sleep_duration = int((earliest_event_time - now) / self.time_scale)
# sleep_duration can be negative, if the next event is in the past
# This indicates that our computer is too slow, and cannot keep up with the simulation.
# Like all things fate-related, we embrace this slowness, rather than fighting it:
# We will temporarily run the simulation at a slower pace, which has the benefit of the simulation remaining responsive to user input.
self.purposefully_behind = min(sleep_duration, 0) # see above comment
actual_sleep_duration = max(sleep_duration, 0) # can never sleep less than 0
return actual_sleep_duration
class AbstractRealTimeSimulation:
# Generate input event at the current wall clock time (with time-scale applied, of course)
# This method should be used for interactive simulation, for generating events that were caused by e.g., button clicks, key presses, ...
@abc.abstractmethod
def add_input_now(self, sc, event, value=None):
pass

View file

@ -0,0 +1,43 @@
import threading
from lib.realtime.realtime import WallClock, AbstractRealTimeSimulation
from lib.controller import Controller, pretty_time
# Runs simulation, real-time, in its own thread
#
# Typical usage:
# thread = threading.Thread(
# target=ThreadedRealTimeSimulation(...).mainloop,
# )
# thread.start()
class ThreadedRealTimeSimulation(AbstractRealTimeSimulation):
def __init__(self, controller: Controller, wall_clock: WallClock, termination_condition = lambda: False):
self.controller = controller
self.wall_clock = wall_clock
self.termination_condition = termination_condition
self.condition = threading.Condition()
def mainloop(self):
while True:
self.controller.run_until(self.wall_clock.time_since_start())
if self.termination_condition():
print("Termination condition satisfied. Stop mainloop.")
return
if self.controller.have_event():
earliest_event_time = self.controller.get_earliest()
sleep_duration = self.wall_clock.sleep_duration_until(earliest_event_time)
with self.condition:
# print('thread sleeping for', pretty_time(sleep_duration), 'or until interrupted')
self.condition.wait(sleep_duration / 1000000000)
# print('thread woke up')
else:
with self.condition:
# print('thread sleeping until interrupted')
self.condition.wait()
def add_input_now(self, sc, event, value=None):
with self.condition:
self.controller.add_input(sc, event,
timestamp=self.wall_clock.time_since_start(),
value=value)
self.condition.notify()

View file

@ -0,0 +1,13 @@
from lib.realtime.event_loop import AbstractEventLoop
# schedules calls in an existing tkinter eventloop
class TkEventLoopAdapter(AbstractEventLoop):
def __init__(self, tk):
self.tk = tk
def schedule(self, delay, callback):
return self.tk.after(int(delay / 1000000), # ns to ms
callback)
def cancel(self, timer):
self.tk.after_cancel(timer)

136
StartingPoint/lib/test.py Normal file
View file

@ -0,0 +1,136 @@
from difflib import ndiff
from lib.controller import Controller, pretty_time
from lib.tracer import Tracer
from lib.yakindu_helpers import YakinduTimerServiceAdapter, trace_output_events
# Can we ignore event in 'trace' at position 'idx' with respect to idempotency?
def can_ignore(trace, idx, IDEMPOTENT):
(timestamp, event_name, value) = trace[idx]
if event_name in IDEMPOTENT:
# If the same event occurred earlier, with the same parameter value, then this event can be ignored:
for (earlier_timestamp, earlier_event_name, earlier_value) in reversed(trace[0:idx]):
if (earlier_event_name, earlier_value) == (event_name, value):
# same event name and same parameter value (timestamps allowed to differ)
return True
elif event_name == earlier_event_name:
# same event name, but different parameter value:
# stop looking into the past:
break
# If the same event occurs later event, but with the same timestamp, this event is overwritten and can be ignored:
for (later_timestamp, later_event_name, later_value) in trace[idx+1:]:
if (later_timestamp, later_event_name) == (timestamp, event_name):
# if a later event with same name and timestamp occurs, ours will be overwritten:
return True
if later_timestamp != timestamp:
# no need to look further into the future:
break
return False
def postprocess_trace(trace, INITIAL, IDEMPOTENT):
# Prepend trace with events that set assumed initial state:
result = [(0, event_name, value) for (event_name, value) in INITIAL] + trace
# Remove events that have no effect:
while True:
filtered = [tup for (idx, tup) in enumerate(result) if not can_ignore(result, idx, IDEMPOTENT)]
# Keep on filtering until no more events could be removed:
if len(filtered) == len(result):
return filtered
result = filtered
def compare_traces(expected, actual):
i = 0
while i < len(expected) and i < len(actual):
# Compare tuples:
if expected[i] != actual[i]:
print("Traces differ!")
# print("expected: (%i, \"%s\", %s)" % expected[i])
# print("actual: (%i, \"%s\", %s)" % actual[i])
return False
i += 1
if len(expected) != len(actual):
print("Traces have different length:")
print("expected length: %i" % len(expected))
print("actual length: %i" % len(actual))
return False
print("Traces match.")
return True
def run_scenario(input_trace, expected_output_trace, statechart_class, INITIAL, IDEMPOTENT, verbose=False):
controller = Controller()
sc = statechart_class()
tracer = Tracer(verbose=False)
controller.input_tracers.append((sc, tracer.record_input_event))
trace_output_events(controller, sc, callback=tracer.record_output_event)
sc.timer_service = YakinduTimerServiceAdapter(controller)
# Put entire input trace in event queue, ready to go!
for tup in input_trace:
(timestamp, event_name, value) = tup
controller.add_input(sc, event_name, timestamp, value)
sc.enter() # enter default state(s)
if len(expected_output_trace) > 0:
last_output_event_timestamp = expected_output_trace[-1][0]
else:
last_output_event_timestamp = 0
# Blocking synchronous call:
controller.run_until(last_output_event_timestamp)
actual_output_trace = tracer.output_events
clean_expected = postprocess_trace(expected_output_trace, INITIAL, IDEMPOTENT)
clean_actual = postprocess_trace(actual_output_trace, INITIAL, IDEMPOTENT)
# clean_expected = expected_output_trace
# clean_actual = actual_output_trace
def print_diff():
# The diff printed will be a diff of the 'raw' traces, not of the cleaned up traces
# A diff of the cleaned up traces would be confusing to the user.
have_plus = False
have_minus = False
have_useless = False
for diffline in ndiff(
[str(tup)+'\n' for tup in expected_output_trace],
[str(tup)+'\n' for tup in actual_output_trace],
charjunk=None,
):
symbol = diffline[0]
if symbol == '+':
have_plus = True
if symbol == '-':
have_minus = True
if symbol == '?':
continue
rest = diffline[2:-1] # drop last character (=newline)
useless_line = (
symbol == '-' and rest not in [str(tup) for tup in clean_expected]
or symbol == '+' and rest not in [str(tup) for tup in clean_actual]
# or symbol == ' ' and rest not in [str(tup) for tup in clean_actual]
)
if useless_line:
print(" (%s) %s" % (symbol, rest))
have_useless = True
else:
print(" %s %s" % (symbol, rest))
if have_minus or have_plus or have_useless:
print("Legend:")
if have_minus:
print(" -: expected, but did not happen")
if have_plus:
print(" +: happened, but was not expected")
if have_useless:
print(" (-) or (+): indicates a \"useless event\" (because it has no effect), either in expected output (-) or in actual output (+).")
print("\n\"Useless events\" are ignored by the comparison algorithm, and will never cause your test to fail. In this assignment, your solution is allowed to contain useless events.")
if not compare_traces(clean_expected, clean_actual):
print("Raw diff between expected and actual output event trace:")
print_diff()
return False
elif verbose:
print_diff()
return True

View file

@ -0,0 +1,36 @@
from lib.controller import pretty_time
# Records input/output events
class Tracer:
def __init__(self, verbose=True):
self.verbose = verbose
self.input_events = []
self.output_events = []
def record_input_event(self, simtime, event_name, value):
if self.verbose:
print(f"time = {pretty_time(simtime)}, input event: {event_name}, value = {value}")
if not event_name.startswith("__timer"):
# we don't record timer events - they are specific to the statechart (not part of any fixed interface), and they are auto-generated by the timer interface
self.input_events.append( (simtime, event_name, value) )
def record_output_event(self, simtime, event_name, value):
if self.verbose:
print(f"time = {pretty_time(simtime)}, output event: {event_name}, value = {value}")
self.output_events.append( (simtime, event_name, value))
def format_trace_as_python_code(trace, indent=0):
txt = "[\n"
for (timestamp, event_name, value) in trace:
txt += (" "*indent)+" (%i, \"%s\", %s),\n" % (timestamp, event_name, value)
txt += (" "*indent)+"],"
return txt
# almost same as Python, but with arrays instead of tuples
def format_trace_as_json(trace, indent=0):
txt = "[\n"
for (timestamp, event_name, value) in trace:
txt += (" "*indent)+" [%i, \"%s\", %s],\n" % (timestamp, event_name, value)
txt += (" "*indent)+"],"
return txt

View file

@ -0,0 +1,5 @@
"""
Empty file that initializes the package it is contained in.
"""

View file

@ -0,0 +1,48 @@
"""Implementation for Observer and Observables used for out events.
Generated by itemis CREATE code generator.
"""
class Observer():
"""Observer implementation.
"""
def next(self, value=None):
"""Abstract next method, which must be implemented."""
raise NotImplementedError('user must define next() to use this base class')
class Observable():
"""Observable implementation.
"""
def __init__(self):
self.observers = []
def next(self, value=None):
"""Calls next function from every observer.
"""
for observer in self.observers:
if observer is not None:
if value is None:
observer.next()
else:
observer.next(value)
def subscribe(self, observer):
"""Subscribe on specified observer.
"""
if observer is not None:
self.observers.append(observer)
return True
return False
def unsubscribe(self, observer):
"""Unsubscribe from specified observer.
"""
if observer is None:
return False
if observer in self.observers:
self.observers.remove(observer)
return True
return False

View file

@ -0,0 +1,74 @@
# In this module, stuff that is specific to Yakindu's generated code
# Author: Joeri Exelmans
from lib.controller import Controller, pretty_time
# for some stupid reason, we have to import the 'Observable' class like this, or `type(obj) == Observable` will fail:
import sys, os
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../lib')))
from yakindu.rx import Observable, Observer
# Adapter to allow Yakindu generated code to (un)set timeouts
# Uses event queue of the underlying Controller, making all timed transitions scheduled in simulated time (instead of wall-clock time as in Yakindu's own TimerService).
class YakinduTimerServiceAdapter:
def __init__(self, controller: Controller):
self.controller = controller;
self.timers = {}
# Duration: milliseconds
def set_timer(self, sc, event_id, duration, periodic):
self.unset_timer(None, event_id)
controller_duration = duration * 1000000 # ms to ns
e = self.controller.add_input_lowlevel(
self.controller.simulated_time + controller_duration, # timestamp relative to simulated time
raise_method=sc.time_elapsed,
value=event_id,
event_name="__timer"+str(event_id))
self.timers[event_id] = e
def unset_timer(self, _, event_id):
try:
e = self.timers[event_id]
e.canceled = True
except KeyError:
pass
# Could not find a better way to get list of output events of a YAKINDU statechart
def iter_output_observables(sc):
for attr in dir(sc):
obj = getattr(sc, attr)
if type(obj) == Observable:
yield (attr[0:-11], obj)
# Useful for debugging
class OutputEventTracer(Observer):
def __init__(self, controller, event_name, callback):
self.controller = controller
self.event_name = event_name
self.callback = callback
def next(self, value=None):
self.callback(self.controller.simulated_time, self.event_name, value)
def trace_output_events(controller, sc, callback, iface=None):
if iface == None:
for event_name, observable in iter_output_observables(sc):
observable.subscribe(OutputEventTracer(controller, event_name, callback))
else:
for event_name, observable in iter_output_observables(getattr(sc, iface)):
full_event_name = iface + '.' + event_name
observable.subscribe(OutputEventTracer(controller, full_event_name, callback))
# Allows use of a simple callback to respond to an output event
class CallbackObserver(Observer):
def __init__(self, callback):
self.callback = callback
def next(self, value=None):
if value == None:
self.callback()
else:
self.callback(value)