diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 720610b..9a6f8c2 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -7,10 +7,21 @@ Unreleased - (Added) Priority can be set for transitions (using *low*, *high* or any integer in yaml). transitions are selected according to their priorities (still following eventless and inner-first/source state semantics). - (Added) A ``sismic.testing`` module containing some primitives to ease unit testing. + - (Added) A ``sismic.clock`` module with a ``Clock`` base class and three direct implementations, + namely ``SimulatedClock``, ``UtcClock`` and ``SynchronizedClock``. A ``SimulatedClock`` allows to manually or automatically + change the time, while a ``WallClock`` as the expected behaviour of a wall-clock. + ``Clock`` instances are used by the interpreter to get the current time during execution. + See documentation for more information. + - (Added) An ``Interpreter.clock`` attribute that stores an instance of the newly added ``Clock`` class. + - (Changed) ``interpreter.time`` represents the time of the last executed step, not the current + time. Use ``interpreter.clock.time`` instead. + - (Changed) ``helpers.run_in_background`` no longer synchronizes the interpreter clock. + Use the ``start()`` method of ``interpreter.clock`` or an ``UtcClock`` instance instead. - (Fixed) State *on entry* time (used for ``idle`` and ``after``) is set after the *on entry* action is executed, making the two predicates more accurate when long-running actions are executed when a state is entered. Similarly, ``idle`` is reset after the action of a transition is performed, not before. + - (Deprecated) Setting ``Interpreter.time`` is deprecated, set time with ``Interpreter.clock.time`` instead. 1.2.2 (2018-06-21) diff --git a/README.rst b/README.rst index 01d3a16..0fceddb 100644 --- a/README.rst +++ b/README.rst @@ -24,6 +24,7 @@ More specifically, Sismic provides: - An easy way to define and to import statecharts, based on the human-friendly YAML markup language - A statechart interpreter offering a discrete, step-by-step, and fully observable simulation engine +- Fully controllable simulation clock, with support for real and simulated time - Built-in support for expressing actions and guards using regular Python code, can be easily extended to other programming languages - A design-by-contract approach for statecharts: contracts can be specified to express invariants, pre- and postconditions on states and transitions - Runtime checking of behavioral properties expressed as statecharts diff --git a/docs/api/clock.rst b/docs/api/clock.rst new file mode 100644 index 0000000..e804f72 --- /dev/null +++ b/docs/api/clock.rst @@ -0,0 +1,11 @@ +Module *clock* +============== + +.. automodule:: sismic.clock + :members: + :member-order: bysource + :show-inheritance: + :inherited-members: + :imported-members: + + diff --git a/docs/examples/stopwatch/stopwatch_gui_external.py b/docs/examples/stopwatch/stopwatch_gui_external.py index 2ab1367..dd3d288 100644 --- a/docs/examples/stopwatch/stopwatch_gui_external.py +++ b/docs/examples/stopwatch/stopwatch_gui_external.py @@ -29,8 +29,8 @@ def __init__(self, master=None): # Create a stopwatch object and pass it to the interpreter self.stopwatch = Stopwatch() self.interpreter = Interpreter(statechart, initial_context={'stopwatch': self.stopwatch}) - self.interpreter.time = time.time() - + self.interpreter.clock.start() + # Run the interpreter self.run() diff --git a/docs/index.rst b/docs/index.rst index c4d001a..46b515d 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -37,6 +37,7 @@ Sismic provides the following features: - An easy way to define and to import statecharts, based on the human-friendly YAML markup language - A statechart interpreter offering a discrete, step-by-step, and fully observable simulation engine +- Fully controllable simulation clock, with support for real and simulated time - Built-in support for expressing actions and guards using regular Python code, can be easily extended to other programming languages - A design-by-contract approach for statecharts: contracts can be specified to express invariants, pre- and postconditions on states and transitions - Runtime checking of behavioral properties expressed as statecharts diff --git a/docs/time.rst b/docs/time.rst index 63efef0..5fcb477 100644 --- a/docs/time.rst +++ b/docs/time.rst @@ -1,4 +1,3 @@ - Dealing with time ================= @@ -6,26 +5,127 @@ It is quite usual in statecharts to find notations such as "*after 30 seconds*", on a transition. Sismic does not support the use of these *special events*, and proposes instead to deal with time by making use of some specifics provided by its interpreter and the default Python code evaluator. -Every interpreter has an internal clock whose value is initially set to 0. This internal clock is exposed by -by the :py:attr:`~sismic.interpreter.Interpreter.time` property of an :py:class:`~sismic.interpreter.Interpreter`. -This property allows one to execute a statechart using simulated time. In other word, the value of this property -won't change, unless you set it by yourself. +Every interpreter has an internal clock that is exposed through its :py:attr:`~sismic.interpreter.Interpreter.clock` +attribute and that can be used to manipulate the time of the simulation. The built-in Python code evaluator allows one to make use of ``after(...)``, ``idle(...)`` in guards or contracts. -These two Boolean predicates can be used to automatically compare the current time (as exposed by the interpreter) +These two Boolean predicates can be used to automatically compare the current time (as exposed by interpreter clock) with a predefined value that depends on the state in which the predicate is used. For instance, ``after(x)`` will -evaluate to ``True`` if the current time of the interpreter is at least ``x`` units greater than the time when the +evaluate to ``True`` if the current time of the interpreter is at least ``x`` seconds greater than the time when the state using this predicate (or source state in the case of a transition) was entered. -Similarly, ``idle(x)`` evaluates to ``True`` if no transition was triggered during the last ``x`` units of time. +Similarly, ``idle(x)`` evaluates to ``True`` if no transition was triggered during the last ``x`` seconds. + +These two predicates rely on the :py:attr:`~sismic.interpreter.Interpreter.time` attribute of an interpreter. +The value of that attribute is computed at the beginning of each executed step based on a clock. + +Sismic provides three implementations of :py:class:`~sismic.clock.Clock` in its :py:mod:`sismic.clock` module. +The first one is a :py:class:`~sismic.clock.SimulatedClock` that can be manually or automatically incremented. In the latter case, +the speed of the clock can be easily changed. The second implementation is a classical :py:class:`~sismic.clock.UtcClock` that corresponds +to a wall-clock in UTC with no flourish. The third implemention is a :py:class:`~sismic.clock.SynchronizedClock` that synchronizes its time value +based on the one of an interpreter. Its main use case is to support the co-execution of property statecharts. + +By default, the interpreter uses a :py:class:`~sismic.clock.SimulatedClock`. If you want the +interpreter to rely on another kind of clock, pass an instance of :py:class:`~sismic.clock.Clock` +as the ``clock`` parameter of an interpreter constructor. + + +Simulated clock +--------------- + +The default clock is a :py:class:`~sismic.clock.SimulatedClock` instance. +This clock always starts at ``0`` and accumulates the elapsed time. + +Its current time value can be read from the :py:attr:`~sismic.clock.SimulatedClock.time` attribute. +By default, the value of this attribute does not change, unless manually modified (simulated time) or +by starting the clock (using :py:meth:`~sismic.clock.SimulatedClock.start`, wall-clock time). + + +To change the current time of a clock, simply set a new value to the :py:attr:`~sismic.clock.SimulatedClock.time` attribute. +Notice that time is expected to be monotonic: it is not allowed to set a new value that is strictly lower than +the previous one. + +As expected, simulated time can be easily achieved by manually modifying this value: + +.. testcode:: clock + + from sismic.clock import SimulatedClock + + clock = SimulatedClock() + print('initial time:', clock.time) + + clock.time += 10 + print('new time:', clock.time) + +.. testoutput:: clock -Note that while this property was initially designed to manage simulate time, it can also be used to synchronise -the internal clock of an interpreter with the *real* time, i.e. wall-clock time. + initial time: 0 + new time: 10 -Simulated time --------------- +To support real time, a :py:class:`~sismic.clock.SimulatedClock` object has two methods, namely +:py:meth:`~sismic.clock.SimulatedClock.start` and :py:meth:`~sismic.clock.SimulatedClock.stop`. +These methods can be used respectively to start and stop the synchronization with real time. +Internally, the clock relies on Python's ``time.time()`` function. + +.. testcode:: clock + + from time import sleep + + clock = SimulatedClock() + + clock.start() + sleep(0.1) + print('after 0.1: {:.1f}'.format(clock.time)) + + +.. testoutput:: clock + + after 0.1: 0.1 + + +A clock based on real time can also be manually changed during the execution by setting a +new value for its :py:attr:`~sismic.clock.SimulatedClock.time` attribute: + + +.. testcode:: clock + + clock.time = 10 + print('after having been set to 10: {:.1f}'.format(clock.time)) + + sleep(0.1) + print('after 0.1: {:.1f}'.format(clock.time)) + +.. testoutput:: clock + + after having been set to 10: 10.0 + after 0.1: 10.1 -The following example illustrates a statechart modeling the behavior of a simple *elevator*. + +Finally, a simulated clock can be accelerated or slowed down by changing the value +of its :py:attr:`~sismic.clock.SimulatedClock.speed` attribute. By default, the value of this +attribute is set to ``1``. A higher value (e.g. ``2``) means that the clock will be faster +than real time (e.g. 2 times faster), while a lower value slows down the clock. + +.. testcode:: clock + + clock = SimulatedClock() + clock.speed = 100 + + clock.start() + sleep(0.1) + clock.stop() + + print('new time: {:.0f}'.format(clock.time)) + +.. testoutput:: clock + + new time: 10 + + +Example: manual time +~~~~~~~~~~~~~~~~~~~~ + +The following example illustrates a statechart modelling the behavior of a simple *elevator*. If the elevator is sent to the 4th floor then, according to the YAML definition of this statechart, the elevator should automatically go back to the ground floor after 10 seconds. @@ -38,7 +138,7 @@ the elevator should automatically go back to the ground floor after 10 seconds. Rather than waiting for 10 seconds, one can simulate this. First, one should load the statechart and initialize the interpreter: -.. testcode:: clock +.. testcode:: from sismic.io import import_from_yaml from sismic.interpreter import Interpreter, Event @@ -47,11 +147,10 @@ First, one should load the statechart and initialize the interpreter: interpreter = Interpreter(statechart) -The internal clock of our interpreter is ``0``. -This is, ``interpreter.time == 0`` holds. +The time of the internal clock of our interpreter is set to ``0`` by default. We now ask our elevator to go to the 4th floor. -.. testcode:: clock +.. testcode:: interpreter.queue(Event('floorSelected', floor=4)) interpreter.execute() @@ -59,12 +158,12 @@ We now ask our elevator to go to the 4th floor. The elevator should now be on the 4th floor. We inform the interpreter that 2 seconds have elapsed: -.. testcode:: clock +.. testcode:: - interpreter.time += 2 + interpreter.clock.time += 2 print(interpreter.execute()) -.. testoutput:: clock +.. testoutput:: :hide: [] @@ -74,38 +173,29 @@ Of course, nothing happened since the condition ``after(10)`` is not satisfied yet. We now inform the interpreter that 8 additional seconds have elapsed. -.. testcode:: clock +.. testcode:: - interpreter.time += 8 - print(interpreter.execute()) - -.. testoutput:: clock - :hide: + interpreter.clock.time += 8 + interpreter.execute() - [MacroStep(10, [MicroStep(transition=Transition('doorsOpen', 'doorsClosed', event=None), entered_states=['doorsClosed'], exited_states=['doorsOpen'])]), MacroStep(10, [MicroStep(transition=Transition('doorsClosed', 'movingDown', event=None), entered_states=['moving', 'movingDown'], exited_states=['doorsClosed'])]), MacroStep(10, [MicroStep(transition=Transition('movingDown', 'movingDown', event=None), entered_states=['movingDown'], exited_states=['movingDown'])]), MacroStep(10, [MicroStep(transition=Transition('movingDown', 'movingDown', event=None), entered_states=['movingDown'], exited_states=['movingDown'])]), MacroStep(10, [MicroStep(transition=Transition('movingDown', 'movingDown', event=None), entered_states=['movingDown'], exited_states=['movingDown'])]), MacroStep(10, [MicroStep(transition=Transition('moving', 'doorsOpen', event=None), entered_states=['doorsOpen'], exited_states=['movingDown', 'moving'])])] +The elevator must has moved down to the ground floor. +Let's check the current floor: -The output now contains a list of steps, from which we can see that the elevator has moved down to the ground floor. -We can check the current floor: - -.. testcode:: clock +.. testcode:: print(interpreter.context.get('current')) -.. testoutput:: clock - :hide: +.. testoutput:: 0 -This displays ``0``. - +Example: automatic time +~~~~~~~~~~~~~~~~~~~~~~~ -Real or wall-clock time ------------------------ +If the execution of a statechart needs to rely on a real clock, the simplest way to achieve this +is by using the :py:meth:`~sismic.clock.SimulatedClock.start` method of an interpreter clock. -If a statechart needs to be aware of a real clock, the simplest way to achieve this is by using -the :py:func:`time.time` function of Python. -In a nutshell, the idea is to synchronize ``interpreter.time`` with a real clock. Let us first initialize an interpreter using one of our statechart example, the *elevator*: .. testcode:: realclock @@ -117,16 +207,15 @@ Let us first initialize an interpreter using one of our statechart example, the interpreter = Interpreter(statechart) -The interpreter initially sets its clock to 0. -As we are interested in a real-time simulation of the statechart, -we need to set the internal clock of our interpreter. -We import from :py:mod:`time` a real clock, -and store its value into a ``starttime`` variable. +Initially, the internal clock is set to 0. +As we want to simulate the statechart based on real-time, we need to start the clock. +For this example, as we don't want to have to wait 10 seconds for the elevator to +move to the ground floor, we speed up the internal clock by a factor of 100: .. testcode:: realclock - import time - starttime = time.time() + interpreter.clock.speed = 100 + interpreter.clock.start() We can now execute the statechart by sending a ``floorSelected`` event, and wait for the output. For our example, we first ask the statechart to send to elevator to the 4th floor. @@ -136,86 +225,76 @@ For our example, we first ask the statechart to send to elevator to the 4th floo interpreter.queue(Event('floorSelected', floor=4)) interpreter.execute() print('Current floor:', interpreter.context.get('current')) - print('Current time:', interpreter.time) + print('Current time:', int(interpreter.clock.time)) At this point, the elevator is on the 4th floor and is waiting for another input event. -The internal clock value is still 0. +The internal clock value is still close to 0. .. testoutput:: realclock Current floor: 4 Current time: 0 -We should inform our interpreter of the new current time. -Of course, as our interpreter follows a discrete simulation, nothing really happens until we call -:py:meth:`~sismic.interpreter.Interpreter.execute` or :py:meth:`~sismic.interpreter.Interpreter.execute_once`. +Let's wait 0.1 second (remember that we speed up the internal clock, so 0.1 second means 10 seconds +for our elevator): .. testcode:: realclock - interpreter.time = time.time() - starttime - # Does nothing if (time.time() - starttime) is less than 10! + from time import sleep + + sleep(0.1) interpreter.execute() -Assuming you quickly wrote these lines of code, nothing happened. -But if you wait a little bit, and update the clock again, it should move the elevator to the ground floor. +We can now check that our elevator is on the ground floor: .. testcode:: realclock - interpreter.time = time.time() - starttime - interpreter.execute() + print(interpreter.context.get('current')) -And *voilĂ *! +.. testoutput:: realclock -As it is not very convenient to manually set the clock each time you want to execute something, it is best to -put it in a loop. To avoid the use of a ``starttime`` variable, you can set the initial time of an interpreter -right after its initialization. -This is illustrated in the following example. + 0 -.. code:: python - from sismic.io import import_from_yaml - from sismic.interpreter import Interpreter, import Event +Wall-clock +---------- - import time +The second clock provided by Sismic is a :py:class:`~sismic.clock.UtcClock` whose time +is synchronized with system time (it relies on the ``time.time()`` function of Python). - # Load statechart and create an interpreter - statechart = import_from_yaml(filepath='examples/elevator.yaml') - # Set the initial time - interpreter = Interpreter(statechart) - interpreter.time = time.time() +.. testcode:: - # Send an initial event - interpreter.queue(Event('floorSelected', floor=4)) + from sismic.clock import UtcClock + from time import time - while not interpreter.final: - interpreter.time = time.time() - if interpreter.execute(): - print('something happened at time {}'.format(interpreter.time)) + clock = UtcClock() + assert (time() - clock.time) <= 1 - time.sleep(0.5) # 500ms -Here, we called the :py:func:`~time.sleep` function to slow down the loop (optional). -The output should look like:: +Synchronized clock +------------------ - something happened at time 1450383083.9943285 - something happened at time 1450383093.9920669 +The third clock is a :py:class:`~sismic.clock.SynchronizedClock` that expects an +:py:class:`~sismic.interpreter.Interpreter` instance, and synchronizes its time +value based on the value of the ``time`` attribute of the interpreter. -As our statechart does not define any way to reach a final configuration, -the ``not interpreter.final`` condition always holds, -and the execution needs to be interrupted manually. +The main use cases are when statechart executions have to be synchronized to the +point where a shared clock instance is not sufficient because executions should +occur at exactly the same time, up to the milliseconds. Internally, this clock +is used when property statecharts are bound to an interpreter, as they need to be +executed at the exact same time. -Asynchronous execution ----------------------- +Implementing other clocks +------------------------- -Notice from previous example that using a loop makes it impossible to send events to the interpreter. -For convenience, sismic provides a :py:func:`sismic.helpers.run_in_background` -function that run an interpreter in a thread, and does the job of synchronizing the clock for you. +You can quite easily write your own clock implementation, for example if you need to +synchronize different distributed interpreters. +Simply subclass the :py:class:`~sismic.clock.Clock` base class. -.. autofunction:: sismic.helpers.run_in_background +.. autoclass:: sismic.clock.Clock + :members: + :member-order: bysource :noindex: -.. note:: An optional argument ``callback`` can be passed to :py:func:`~sismic.interpreter.helpers.run_in_background`. - It must be a callable that accepts the (possibly empty) list of :py:class:`~sismic.interpreter.MacroStep` returned by - the underlying call to :py:meth:`~sismic.interpreter.Interpreter.execute`. diff --git a/sismic/bdd/steps.py b/sismic/bdd/steps.py index a002c7c..3848a1a 100644 --- a/sismic/bdd/steps.py +++ b/sismic/bdd/steps.py @@ -59,7 +59,7 @@ def send_event(context, name, parameter=None, value=None): @when('I wait {seconds:g} seconds') @when('I wait {seconds:g} second') def wait(context, seconds): - context.interpreter.time += seconds + context.interpreter.clock.time += seconds @then('state {name} is entered') diff --git a/sismic/clock/__init__.py b/sismic/clock/__init__.py new file mode 100644 index 0000000..12c8969 --- /dev/null +++ b/sismic/clock/__init__.py @@ -0,0 +1,3 @@ +from .clock import Clock, SimulatedClock, UtcClock, SynchronizedClock + +__all__ = ['Clock', 'SimulatedClock', 'UtcClock', 'SynchronizedClock'] \ No newline at end of file diff --git a/sismic/clock/clock.py b/sismic/clock/clock.py new file mode 100644 index 0000000..9d6b09b --- /dev/null +++ b/sismic/clock/clock.py @@ -0,0 +1,139 @@ +import abc + +from numbers import Number +from time import time + + +__all__ = ['Clock', 'SimulatedClock', 'UtcClock', 'SynchronizedClock'] + + +class Clock(metaclass=abc.ABCMeta): + """ + Abstract implementation of a clock, as used by an interpreter. + + The purpose of a clock instance is to provide a way for the interpreter + to get the current time during the execution of a statechart. + """ + @abc.abstractproperty + def time(self) -> Number: + """ + Current time + """ + raise NotImplementedError() + + def __repr__(self): + return '{}[{}]'.format(self.__class__.__name__, self.time) + + +class SimulatedClock(Clock): + """ + A simulated clock, starting from 0, that can be manually or automatically + incremented. + + Manual incrementation can be done by setting a new value to the time attribute. + Automatic incrementation occurs when start() is called, until stop() is called. + In that case, clock speed can be adjusted with the speed attribute. + A value strictly greater than 1 increases clock speed while a value strictly + lower than 1 slows down the clock. + """ + def __init__(self): + self._base = time() + self._time = 0 + self._play = False + self._speed = 1 + + @property + def _elapsed(self): + return (time() - self._base) * self._speed if self._play else 0 + + def start(self): + """ + Clock will be automatically updated both based on real time and + its speed attribute. + """ + if not self._play: + self._base = time() + self._play = True + + def stop(self): + """ + Clock won't be automatically updated. + """ + if self._play: + self._time += self._elapsed + self._play = False + + @property + def speed(self): + """ + Speed of the current clock. Only affects time if start() is called. + """ + return self._speed + + @speed.setter + def speed(self, speed): + self._time += self._elapsed + self._base = time() + self._speed = speed + + @property + def time(self): + """ + Time value of this clock. + """ + return self._time + self._elapsed + + @time.setter + def time(self, new_time): + """ + Set the time of this clock. + + :param new_time: new time + """ + current_time = self.time + if new_time < current_time: + raise ValueError('Time must be monotonic, cannot change time from {} to {}'.format(current_time, new_time)) + + self._time = new_time + self._base = time() + + def __str__(self): + return '{:.2f}'.format(float(self.time)) + + def __repr__(self): + return '{}[{:.2f},x{},{}]'.format( + self.__class__.__name__, + self.time, + self._speed, + '>' if self._play else '=', + ) + + +class UtcClock(Clock): + """ + A clock that simulates a wall clock in UTC. + + The returned time value is based on Python time.time() function. + """ + + @property + def time(self): + return time() + + +class SynchronizedClock(Clock): + """ + A clock that is synchronized with a given interpreter. + + The synchronization is based on the interpreter's internal time value, not + on its clock. As a consequence, the time value of a SynchronizedClock only + changes when the underlying interpreter is executed. + + :param interpreter: an interpreter instance + """ + def __init__(self, interpreter): + self._interpreter = interpreter + + @property + def time(self): + return self._interpreter.time diff --git a/sismic/code/python.py b/sismic/code/python.py index a4ee40d..68efa6e 100644 --- a/sismic/code/python.py +++ b/sismic/code/python.py @@ -67,7 +67,7 @@ class PythonEvaluator(Evaluator): Depending on the method that is called, the context can expose additional values: - On both code execution and code evaluation: - - A *time: float* value that represents the current time exposed by the interpreter. + - A *time: float* value that represents the current time exposed by interpreter clock. - An *active(name: str) -> bool* Boolean function that takes a state name and return *True* if and only if this state is currently active, ie. it is in the active configuration of the ``Interpreter`` instance that makes use of this evaluator. diff --git a/sismic/helpers.py b/sismic/helpers.py index 2472254..9ff8179 100644 --- a/sismic/helpers.py +++ b/sismic/helpers.py @@ -35,8 +35,7 @@ def run_in_background(interpreter: Interpreter, delay: float=0.05, callback: Callable[[List[MacroStep]], Any]=None) -> threading.Thread: """ - Run given interpreter in background. The time is updated according to - *time.time() - starttime*. The interpreter is ran until it reaches a final configuration. + Run given interpreter in background. The interpreter is ran until it reaches a final configuration. You can manually stop the thread using the added *stop* of the returned Thread object. This is for convenience only and should be avoided, because a call to *stop* puts the interpreter in an empty (and thus final) configuration, without properly leaving the active states. @@ -49,9 +48,7 @@ def run_in_background(interpreter: Interpreter, import time def _task(): - starttime = time.time() while not interpreter.final: - interpreter.time = time.time() - starttime steps = interpreter.execute() if callback: callback(steps) diff --git a/sismic/interpreter/default.py b/sismic/interpreter/default.py index ffb3130..c30443d 100644 --- a/sismic/interpreter/default.py +++ b/sismic/interpreter/default.py @@ -1,3 +1,6 @@ +import warnings + +from numbers import Number from collections import deque, defaultdict from itertools import combinations from typing import Any, Callable, Dict, Iterable, List, Mapping, Optional, Set, Union, cast, Tuple @@ -8,6 +11,7 @@ except ImportError: pass +from ..clock import Clock, SimulatedClock, SynchronizedClock from ..model import ( MacroStep, MicroStep, Event, InternalEvent, MetaEvent, Statechart, Transition, @@ -16,7 +20,7 @@ from ..code import Evaluator, PythonEvaluator from ..exceptions import (ConflictingTransitionsError, InvariantError, PropertyStatechartError, - ExecutionError, NonDeterminismError, PostconditionError, PreconditionError) + NonDeterminismError, PostconditionError, PreconditionError) __all__ = ['Interpreter'] @@ -38,17 +42,20 @@ class Interpreter: (eventless transitions first, inner-first/source state semantics). :param statechart: statechart to interpret - :param evaluator_klass: An optional callable (eg. a class) that takes an interpreter and an optional initial - context as input and return an *Evaluator* instance that will be used to initialize the interpreter. + :param evaluator_klass: An optional callable (e.g. a class) that takes an interpreter and an optional initial + context as input and returns an *Evaluator* instance that will be used to initialize the interpreter. By default, the *PythonEvaluator* class will be used. :param initial_context: an optional initial context that will be provided to the evaluator. By default, an empty context is provided + :param clock: A BaseClock instance that will be used to set this interpreter internal time. + By default, a SimulatedClock is used. :param ignore_contract: set to True to ignore contract checking during the execution. """ def __init__(self, statechart: Statechart, *, evaluator_klass: Callable[..., Evaluator]=PythonEvaluator, initial_context: Mapping[str, Any]=None, + clock: Clock=None, ignore_contract: bool=False) -> None: # Internal variables self._ignore_contract = ignore_contract @@ -57,7 +64,8 @@ def __init__(self, statechart: Statechart, *, self._initialized = False # Internal clock - self._time = 0 # type: float + self.clock = SimulatedClock() if clock is None else clock + self._time = self.clock.time # History states memory self._memory = {} # type: Dict[str, Optional[List[str]]] @@ -82,27 +90,17 @@ def __init__(self, statechart: Statechart, *, self._evaluator.execute_statechart(statechart) @property - def time(self) -> float: + def time(self) -> Number: """ - Time value (in seconds) of the internal clock + Time of the latest execution. """ return self._time @time.setter def time(self, value: float): - """ - Set the time of the internal clock - - :param value: time value (in seconds) - """ - if self._time > value: - raise ExecutionError('Time must be monotonic, cannot set time to {} from {}'.format(value, self._time)) - self._time = value - - # Update bound properties - for property_statechart in self._bound_properties: - property_statechart.time = self._time - + warnings.warn('Interpreter.time is deprecated since 1.3.0, use Interpreter.clock.time instead', DeprecationWarning) + self.clock.time = value # type: ignore + @property def configuration(self) -> List[str]: """ @@ -178,7 +176,7 @@ def bind_property_statechart(self, statechart_or_interpreter: Union[Statechart, interpreter = statechart_or_interpreter # Sync clock - interpreter.time = self.time + interpreter.clock = SynchronizedClock(self) # Add to the list of properties self._bound_properties.append(interpreter) @@ -237,6 +235,9 @@ def execute_once(self) -> Optional[MacroStep]: :return: a macro step or *None* if nothing happened """ + # Store time to have a consistent time value during this step + self._time = self.clock.time + # Compute steps computed_steps = self._compute_steps() @@ -262,7 +263,7 @@ def execute_once(self) -> Optional[MacroStep]: executed_steps.append(self._apply_step(step)) executed_steps.extend(self._stabilize()) - macro_step = MacroStep(time=self.time, steps=executed_steps) + macro_step = MacroStep(time=self._time, steps=executed_steps) # Check state invariants configuration = self.configuration # Use self.configuration to benefit from the sorting by depth diff --git a/tests/conftest.py b/tests/conftest.py index 6725884..d79bbb6 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -5,6 +5,7 @@ from sismic.interpreter import Interpreter + @pytest.fixture(params=[False, True], ids=['no contract', 'contract']) def elevator(request): if request.param: diff --git a/tests/test_bdd.py b/tests/test_bdd.py index fc48c42..ab401c4 100644 --- a/tests/test_bdd.py +++ b/tests/test_bdd.py @@ -112,12 +112,12 @@ def test_send_event_with_parameter(self, context): context.interpreter.queue.assert_called_with(Event('event_name', x=1)) def test_wait(self, context): - context.interpreter.time = 0 + context.interpreter.clock.time = 0 steps.wait(context, 3) - assert context.interpreter.time == 3 - + assert context.interpreter.clock.time == 3 + steps.wait(context, 6) - assert context.interpreter.time == 9 + assert context.interpreter.clock.time == 9 def test_state_is_entered(self, context, trace): context.monitored_trace = [] diff --git a/tests/test_clock.py b/tests/test_clock.py new file mode 100644 index 0000000..6610a82 --- /dev/null +++ b/tests/test_clock.py @@ -0,0 +1,109 @@ +import pytest + +from time import sleep +from sismic.clock import SimulatedClock, UtcClock, SynchronizedClock + + +class TestSimulatedClock: + @pytest.fixture() + def clock(self): + return SimulatedClock() + + def test_initial_value(self, clock): + assert clock.time == 0 + + def test_manual_increment(self, clock): + clock.time += 1 + assert clock.time == 1 + + def test_monotonicity(self, clock): + clock.time = 10 + with pytest.raises(ValueError): + clock.time = 0 + + def test_automatic_increment(self, clock): + clock.start() + sleep(0.1) + assert clock.time >= 0.1 + + clock.stop() + value = clock.time + sleep(0.1) + assert clock.time == value + + def test_increment(self, clock): + clock.start() + sleep(0.1) + assert clock.time >= 0.1 + clock.time = 10 + assert 10 <= clock.time < 10.1 + clock.stop() + clock.time = 20 + assert clock.time == 20 + + def test_speed_with_manual(self, clock): + clock.speed = 2 + clock.time += 10 + assert clock.time == 10 + + def test_speed_with_automatic(self, clock): + clock.speed = 2 + clock.start() + sleep(0.1) + assert clock.time >= 0.2 + + clock.stop() + clock.time = 10 + clock.speed = 0.1 + + clock.start() + sleep(0.1) + clock.stop() + + assert 10 < clock.time < 10.1 + + def test_start_stop(self, clock): + clock.start() + sleep(0.1) + clock.stop() + sleep(0.1) + clock.start() + sleep(0.1) + clock.stop() + + assert 0.2 <= clock.time < 0.3 + + +class TestUtcClock: + @pytest.fixture() + def clock(self): + return UtcClock() + + def test_increase(self, clock): + current_time = clock.time + sleep(0.1) + assert clock.time > current_time + + +class TestSynchronizedClock(): + @pytest.fixture() + def interpreter(self, mocker): + interpreter = mocker.MagicMock() + interpreter.time = 0 + return interpreter + + @pytest.fixture() + def clock(self, interpreter): + return SynchronizedClock(interpreter) + + def test_init(self, clock): + assert clock.time == 0 + + def test_sync(self, clock, interpreter): + interpreter.time = 3 + assert clock.time == 3 + + def test_no_sync_with_clock(self, clock, interpreter): + interpreter.clock.time = 3 + assert clock.time == 0 + \ No newline at end of file diff --git a/tests/test_examples.py b/tests/test_examples.py index 33f07ba..afc3a31 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -45,7 +45,7 @@ def test_floor_selected_and_reached(self, elevator): elevator.queue(Event('floorSelected', floor=4)).execute() assert elevator.context['current'] == 4 - elevator.time += 10 + elevator.clock.time += 10 elevator.execute() assert 'doorsOpen' in elevator.configuration diff --git a/tests/test_interpreter.py b/tests/test_interpreter.py index b07ce0b..614dfad 100644 --- a/tests/test_interpreter.py +++ b/tests/test_interpreter.py @@ -1,5 +1,4 @@ import pytest - import pickle from collections import Counter @@ -29,14 +28,18 @@ def test_init(self, interpreter): def test_time(self, interpreter): assert interpreter.time == 0 - interpreter.time += 10 - assert interpreter.time == 10 + interpreter.clock.time += 10 + # No execution means no new time value + assert interpreter.time == 0 - interpreter.time = 20 - assert interpreter.time == 20 + interpreter.execute() + assert interpreter.time == 10 - with pytest.raises(ExecutionError): - interpreter.time = 10 + def test_deprecated_time(self, interpreter): + with pytest.warns(DeprecationWarning): + interpreter.time += 1 + assert interpreter.time == 0 + assert interpreter.clock.time == 1 def test_queue(self, interpreter): interpreter.queue(Event('e1')) @@ -532,6 +535,7 @@ def test_run_in_background(elevator): task.stop() assert elevator.context['current'] == 4 + assert elevator.time == 0 class TestCoverageFromTrace: diff --git a/tests/test_property.py b/tests/test_property.py index 86697c7..c7d78cf 100644 --- a/tests/test_property.py +++ b/tests/test_property.py @@ -21,7 +21,7 @@ def property_statechart(self, microwave, mocker): def test_synchronised_time(self, microwave, property_statechart): assert microwave.time == property_statechart.time - microwave.time += 10 + microwave.clock.time += 10 assert microwave.time == property_statechart.time def test_empty_step(self, microwave, property_statechart):