From 049cff91726d50666a3c667d32fd6d30e4af0fc8 Mon Sep 17 00:00:00 2001 From: Alexandre Decan Date: Tue, 26 Jun 2018 14:42:23 +0200 Subject: [PATCH 1/6] New clock-based implemention of time --- CHANGELOG.rst | 5 + README.rst | 1 + .../stopwatch/stopwatch_gui_external.py | 4 +- docs/index.rst | 1 + docs/time.rst | 238 ++++++++++-------- sismic/bdd/steps.py | 2 +- sismic/code/python.py | 16 +- sismic/helpers.py | 4 +- sismic/interpreter/__init__.py | 3 +- sismic/interpreter/clock.py | 89 +++++++ sismic/interpreter/default.py | 34 ++- tests/conftest.py | 1 + tests/test_bdd.py | 6 +- tests/test_clock.py | 71 ++++++ tests/test_examples.py | 2 +- tests/test_interpreter.py | 24 +- tests/test_property.py | 6 +- 17 files changed, 352 insertions(+), 155 deletions(-) create mode 100644 sismic/interpreter/clock.py create mode 100644 tests/test_clock.py diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 720610b..33cc2a7 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -7,10 +7,15 @@ 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) An ``Interpreter.clock`` attribute that stores an instance of the newly added ``Clock`` class. + This clock can be used to represent simulated and real time. See documentation for more information. + - (Changed) ``helpers.run_in_background`` no longer synchronizes the interpreter clock. + Use ``interpreter.clock.start()`` 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) ``Interpreter.time`` is deprecated, use ``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/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..ca58ba0 100644 --- a/docs/time.rst +++ b/docs/time.rst @@ -6,21 +6,111 @@ 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. -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. +Interpreter clock +----------------- + +The clock of an interpreter is an instance of :py:class:`~sismic.interpreter.Clock` and is +exposed through its :py:attr:`~sismic.interpreter.Interpreter.clock` attribute. + +The clock always starts at ``0`` and accumulates the elapsed time. +Its current time value can be read from the :py:attr:`~sismic.interpreter.Clock.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.interpreter.Clock.start`, wall-clock time). + + +To change the current time of a clock, simply set a new value to the :py:attr:`~sismic.interpreter.Clock.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.interpreter import Clock + + clock = Clock() + print('initial time:', clock.time) + + clock.time += 10 + print('new time:', clock.time) + +.. testoutput:: clock + + initial time: 0 + new time: 10 + + +To support real time, a :py:class:`~sismic.interpreter.Clock` object has two methods, namely +:py:meth:`~sismic.interpreter.Clock.start` and :py:meth:`~sismic.interpreter.Clock.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 = Clock() + + 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.interpreter.Clock.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 + + +Finally, a clock based on real time can be accelerated or slowed down by changing the value +of its :py:attr:`~sismic.interpreter.Clock.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 = Clock() + clock.speed = 100 + + clock.start() + sleep(0.1) + clock.stop() + + print('new time: {:.0f}'.format(clock.time)) + +.. testoutput:: clock + + new time: 10 + + Simulated time -------------- @@ -38,7 +128,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 +137,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 +148,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 +163,30 @@ 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 - - interpreter.time += 8 - print(interpreter.execute()) +.. testcode:: -.. testoutput:: clock - :hide: - - [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'])])] + interpreter.clock.time += 8 + interpreter.execute() -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: +The elevator must has moved down to the ground floor. +Let's check the current floor: -.. testcode:: clock +.. testcode:: print(interpreter.context.get('current')) -.. testoutput:: clock - :hide: +.. testoutput:: 0 -This displays ``0``. - Real or wall-clock time ----------------------- -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. +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.interpreter.Clock.start` method of an interpreter clock. + Let us first initialize an interpreter using one of our statechart example, the *elevator*: .. testcode:: realclock @@ -117,16 +198,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 +216,32 @@ 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! - interpreter.execute() + from time import sleep -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. - -.. testcode:: realclock - - interpreter.time = time.time() - starttime + sleep(0.1) interpreter.execute() -And *voilĂ *! - -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. - -.. code:: python - - from sismic.io import import_from_yaml - from sismic.interpreter import Interpreter, import Event - - import time - - # 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() - - # Send an initial event - interpreter.queue(Event('floorSelected', floor=4)) - - while not interpreter.final: - interpreter.time = time.time() - if interpreter.execute(): - print('something happened at time {}'.format(interpreter.time)) - - 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:: - - something happened at time 1450383083.9943285 - something happened at time 1450383093.9920669 +We can now check that our elevator is on the ground floor: -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. - - -Asynchronous execution ----------------------- +.. testcode:: realclock -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. + print(interpreter.context.get('current')) -.. autofunction:: sismic.helpers.run_in_background - :noindex: +.. testoutput:: realclock -.. 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`. + 0 \ No newline at end of file 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/code/python.py b/sismic/code/python.py index a4ee40d..c8e0925 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. @@ -167,7 +167,7 @@ def _after(self, name: str, seconds: float) -> bool: :param seconds: elapsed time :return: True if given state was entered more than *seconds* ago. """ - return self._interpreter.time - seconds >= self._entry_time[name] + return self._interpreter.clock.time - seconds >= self._entry_time[name] def _idle(self, name: str, seconds: float) -> bool: """ @@ -177,7 +177,7 @@ def _idle(self, name: str, seconds: float) -> bool: :param seconds: elapsed time :return: True if given state was the target of a transition more than *seconds* ago. """ - return self._interpreter.time - seconds >= self._idle_time[name] + return self._interpreter.clock.time - seconds >= self._idle_time[name] def _evaluate_code(self, code: Optional[str], *, additional_context: Mapping=None) -> bool: """ @@ -196,7 +196,7 @@ def _evaluate_code(self, code: Optional[str], *, additional_context: Mapping=Non exposed_context = { 'active': self._active, - 'time': self._interpreter.time, + 'time': self._interpreter.clock.time, } exposed_context.update(additional_context if additional_context is not None else {}) @@ -227,7 +227,7 @@ def _execute_code(self, code: Optional[str], *, additional_context: Mapping=None 'active': self._active, 'send': create_send_function(sent_events, InternalEvent), 'notify': create_send_function(sent_events, MetaEvent), - 'time': self._interpreter.time, + 'time': self._interpreter.clock.time, } exposed_context.update(additional_context if additional_context is not None else {}) @@ -275,7 +275,7 @@ def execute_action(self, transition: Transition, event: Optional[Event]=None) -> """ execution = self._execute_code(getattr(transition, 'action', None), additional_context={'event': event}) - self._idle_time[transition.source] = self._interpreter.time + self._idle_time[transition.source] = self._interpreter.clock.time return execution @@ -289,8 +289,8 @@ def execute_on_entry(self, state: StateMixin) -> List[Event]: """ execution = self._execute_code(getattr(state, 'on_entry', None)) - self._entry_time[state.name] = self._interpreter.time - self._idle_time[state.name] = self._interpreter.time + self._entry_time[state.name] = self._interpreter.clock.time + self._idle_time[state.name] = self._interpreter.clock.time return execution diff --git a/sismic/helpers.py b/sismic/helpers.py index 2472254..6dba8ca 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. @@ -51,7 +50,6 @@ def run_in_background(interpreter: Interpreter, 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/__init__.py b/sismic/interpreter/__init__.py index efe4433..e0b5f07 100644 --- a/sismic/interpreter/__init__.py +++ b/sismic/interpreter/__init__.py @@ -1,4 +1,5 @@ from .default import Interpreter +from .clock import Clock from ..model.events import Event, InternalEvent, MetaEvent -__all__ = ['Interpreter', 'Event', 'InternalEvent', 'MetaEvent'] +__all__ = ['Interpreter', 'Clock', 'Event', 'InternalEvent', 'MetaEvent'] diff --git a/sismic/interpreter/clock.py b/sismic/interpreter/clock.py new file mode 100644 index 0000000..ae26238 --- /dev/null +++ b/sismic/interpreter/clock.py @@ -0,0 +1,89 @@ +from time import time + + +class Clock: + """ + A basic implementation of a clock to represent both simulated and real time. + + The current time of the clock is exposed through the time attribute. + + By default, the clock follows simulated time. Methods start() and stop() can + be used to "synchronize" the clock based on real time. 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. + + The clock can also be manually adjusted by setting its time attribute. + """ + 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 real time clock. + """ + 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 'Clock[{:.2f},x{},{}]'.format( + self.time, + self._speed, + '>' if self._play else '=', + ) + + \ No newline at end of file diff --git a/sismic/interpreter/default.py b/sismic/interpreter/default.py index ffb3130..408774a 100644 --- a/sismic/interpreter/default.py +++ b/sismic/interpreter/default.py @@ -1,3 +1,5 @@ +import warnings + 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 +10,7 @@ except ImportError: pass +from .clock import Clock from ..model import ( MacroStep, MicroStep, Event, InternalEvent, MetaEvent, Statechart, Transition, @@ -16,7 +19,7 @@ from ..code import Evaluator, PythonEvaluator from ..exceptions import (ConflictingTransitionsError, InvariantError, PropertyStatechartError, - ExecutionError, NonDeterminismError, PostconditionError, PreconditionError) + NonDeterminismError, PostconditionError, PreconditionError) __all__ = ['Interpreter'] @@ -57,7 +60,7 @@ def __init__(self, statechart: Statechart, *, self._initialized = False # Internal clock - self._time = 0 # type: float + self.clock = Clock() # History states memory self._memory = {} # type: Dict[str, Optional[List[str]]] @@ -84,25 +87,18 @@ def __init__(self, statechart: Statechart, *, @property def time(self) -> float: """ - Time value (in seconds) of the internal clock + Time value (in seconds) of the internal clock. + + Deprecated since 1.3.0, use Interpreter.clock.time instead. """ - return self._time + warnings.warn('Interpreter.time is deprecated since 1.3.0, use Interpreter.clock.time instead', DeprecationWarning) + return self.clock.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 + @property def configuration(self) -> List[str]: """ @@ -178,7 +174,7 @@ def bind_property_statechart(self, statechart_or_interpreter: Union[Statechart, interpreter = statechart_or_interpreter # Sync clock - interpreter.time = self.time + interpreter.clock = self.clock # Add to the list of properties self._bound_properties.append(interpreter) @@ -262,7 +258,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.clock.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..429df6b 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..cc88759 --- /dev/null +++ b/tests/test_clock.py @@ -0,0 +1,71 @@ +import pytest + +from time import sleep +from sismic.interpreter import Clock + + +class TestClock: + @pytest.fixture() + def clock(self): + return Clock() + + 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_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 + + \ 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..9f7ed4f 100644 --- a/tests/test_interpreter.py +++ b/tests/test_interpreter.py @@ -1,5 +1,4 @@ import pytest - import pickle from collections import Counter @@ -27,16 +26,24 @@ def test_init(self, interpreter): assert not interpreter.final def test_time(self, interpreter): - assert interpreter.time == 0 + assert interpreter.clock.time == 0 + + interpreter.clock.time += 10 + assert interpreter.clock.time == 10 - interpreter.time += 10 - assert interpreter.time == 10 + interpreter.clock.time = 20 + assert interpreter.clock.time == 20 - interpreter.time = 20 - assert interpreter.time == 20 + with pytest.raises(ValueError): + interpreter.clock.time = 10 - with pytest.raises(ExecutionError): - interpreter.time = 10 + def test_deprecated_time(self, interpreter): + with pytest.warns(DeprecationWarning): + assert interpreter.time == 0 + + with pytest.warns(DeprecationWarning): + interpreter.time += 1 + assert interpreter.time == 1 def test_queue(self, interpreter): interpreter.queue(Event('e1')) @@ -532,6 +539,7 @@ def test_run_in_background(elevator): task.stop() assert elevator.context['current'] == 4 + assert elevator.clock.time == 0 class TestCoverageFromTrace: diff --git a/tests/test_property.py b/tests/test_property.py index 86697c7..f23caf6 100644 --- a/tests/test_property.py +++ b/tests/test_property.py @@ -20,9 +20,9 @@ def property_statechart(self, microwave, mocker): return property def test_synchronised_time(self, microwave, property_statechart): - assert microwave.time == property_statechart.time - microwave.time += 10 - assert microwave.time == property_statechart.time + assert microwave.clock.time == property_statechart.clock.time + microwave.clock.time += 10 + assert microwave.clock.time == property_statechart.clock.time def test_empty_step(self, microwave, property_statechart): microwave.execute() From 58cf5a7b82234ce24f9d2be9b45aa9c63f522b5f Mon Sep 17 00:00:00 2001 From: Alexandre Decan Date: Wed, 27 Jun 2018 10:29:54 +0200 Subject: [PATCH 2/6] SimulatedClock and WallClock --- CHANGELOG.rst | 8 +- docs/api/clock.rst | 11 +++ docs/time.rst | 85 ++++++++++++----- sismic/clock/__init__.py | 3 + sismic/clock/clock.py | 162 +++++++++++++++++++++++++++++++++ sismic/interpreter/__init__.py | 3 +- sismic/interpreter/clock.py | 89 ------------------ sismic/interpreter/default.py | 18 +++- tests/test_clock.py | 46 +++++++++- 9 files changed, 299 insertions(+), 126 deletions(-) create mode 100644 docs/api/clock.rst create mode 100644 sismic/clock/__init__.py create mode 100644 sismic/clock/clock.py delete mode 100644 sismic/interpreter/clock.py diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 33cc2a7..90b8ec5 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -7,10 +7,14 @@ 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 ``BaseClock`` base class and two direct implementations, + namely ``SimulatedClock`` and ``WallClock``. 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. - This clock can be used to represent simulated and real time. See documentation for more information. - (Changed) ``helpers.run_in_background`` no longer synchronizes the interpreter clock. - Use ``interpreter.clock.start()`` instead. + Use the ``start()`` method of ``interpreter.clock`` or a ``WallClock`` 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 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/time.rst b/docs/time.rst index ca58ba0..0763cb3 100644 --- a/docs/time.rst +++ b/docs/time.rst @@ -1,4 +1,3 @@ - Dealing with time ================= @@ -16,20 +15,28 @@ evaluate to ``True`` if the current time of the interpreter is at least ``x`` se 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`` seconds. +Sismic provides two implementations of :py:class:`~sismic.clock.BaseClock` 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.WallClock` with +no flourish. + +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.BaseClock` +as the ``clock`` parameter of an interpreter constructor. + -Interpreter clock ------------------ +Simulated clock +--------------- -The clock of an interpreter is an instance of :py:class:`~sismic.interpreter.Clock` and is -exposed through its :py:attr:`~sismic.interpreter.Interpreter.clock` attribute. +The default clock is a :py:class:`~sismic.clock.SimulatedClock` instance. +This clock always starts at ``0`` and accumulates the elapsed time. -The clock always starts at ``0`` and accumulates the elapsed time. -Its current time value can be read from the :py:attr:`~sismic.interpreter.Clock.time` attribute. +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.interpreter.Clock.start`, wall-clock time). +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.interpreter.Clock.time` attribute. +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. @@ -37,9 +44,9 @@ As expected, simulated time can be easily achieved by manually modifying this va .. testcode:: clock - from sismic.interpreter import Clock + from sismic.clock import SimulatedClock - clock = Clock() + clock = SimulatedClock() print('initial time:', clock.time) clock.time += 10 @@ -51,8 +58,8 @@ As expected, simulated time can be easily achieved by manually modifying this va new time: 10 -To support real time, a :py:class:`~sismic.interpreter.Clock` object has two methods, namely -:py:meth:`~sismic.interpreter.Clock.start` and :py:meth:`~sismic.interpreter.Clock.stop`. +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. @@ -60,7 +67,7 @@ Internally, the clock relies on Python's ``time.time()`` function. from time import sleep - clock = Clock() + clock = SimulatedClock() clock.start() sleep(0.1) @@ -73,7 +80,7 @@ Internally, the clock relies on Python's ``time.time()`` function. A clock based on real time can also be manually changed during the execution by setting a -new value for its :py:attr:`~sismic.interpreter.Clock.time` attribute: +new value for its :py:attr:`~sismic.clock.SimulatedClock.time` attribute: .. testcode:: clock @@ -91,13 +98,13 @@ new value for its :py:attr:`~sismic.interpreter.Clock.time` attribute: Finally, a clock based on real time can be accelerated or slowed down by changing the value -of its :py:attr:`~sismic.interpreter.Clock.speed` attribute. By default, the value of this +of its :py:attr:`~sismic.clock.BaseClock.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 = Clock() + clock = SimulatedClock() clock.speed = 100 clock.start() @@ -110,10 +117,9 @@ than real time (e.g. 2 times faster), while a lower value slows down the clock. new time: 10 - -Simulated time --------------- +Example: manual time +~~~~~~~~~~~~~~~~~~~~ The following example illustrates a statechart modeling the behavior of a simple *elevator*. If the elevator is sent to the 4th floor then, according to the YAML definition of this statechart, @@ -180,12 +186,11 @@ Let's check the current floor: 0 - -Real or wall-clock time ------------------------ +Example: automatic 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.interpreter.Clock.start` method of an interpreter clock. +is by using the :py:meth:`~sismic.clock.SimulatedClock.start` method of an interpreter clock. Let us first initialize an interpreter using one of our statechart example, the *elevator*: @@ -244,4 +249,34 @@ We can now check that our elevator is on the ground floor: .. testoutput:: realclock - 0 \ No newline at end of file + 0 + + +Wall-clock +---------- + +The second clock provided by Sismic is a :py:class:`~sismic.clock.WallClock` whose time +is synchronized with system time (it relies on the ``time.time()`` function of Python). + + +.. testcode:: + + from sismic.clock import WallClock + from time import time + + clock = WallClock() + assert (time() - clock.time) <= 1 + + +Implementing other clocks +------------------------- + +You can quite easily write your own clock implementation, for example if you need to +synchronize different distributed interpreters or if you want your clock to ignore +Sismic processing time. Simply subclass the :py:class:`~sismic.clock.BaseClock` base class. + +.. autoclass:: sismic.clock.BaseClock + :members: + :member-order: bysource + :noindex: + diff --git a/sismic/clock/__init__.py b/sismic/clock/__init__.py new file mode 100644 index 0000000..5034aaa --- /dev/null +++ b/sismic/clock/__init__.py @@ -0,0 +1,3 @@ +from .clock import BaseClock, SimulatedClock, WallClock + +__all__ = ['BaseClock', 'SimulatedClock', 'WallClock'] \ No newline at end of file diff --git a/sismic/clock/clock.py b/sismic/clock/clock.py new file mode 100644 index 0000000..d3dcf7b --- /dev/null +++ b/sismic/clock/clock.py @@ -0,0 +1,162 @@ +import abc + +from time import time + + +__all__ = ['BaseClock', 'SimulatedClock', 'WallClock'] + + +class BaseClock(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. + + There are two important properties that must be satisfied by any + implementation: (1) time is expected to be monotonic; and (2) returned time + is expected to remain constant between calls to split() and unsplit(). + """ + @abc.abstractproperty + def time(self): + """ + Current time + """ + raise NotImplementedError() + + @abc.abstractmethod + def split(self): + """ + Freeze current time until unsplit() is called. + """ + raise NotImplementedError() + + @abc.abstractmethod + def unsplit(self): + """ + Unfreeze current time. + """ + raise NotImplementedError() + + +class SimulatedClock(BaseClock): + """ + 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 + self._split = None + + @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 + + def split(self): + """ + Freeze current time until unsplit is called. + """ + self._split = self.time + + def unsplit(self): + """ + Unfreeze current time. + """ + self._split = None + + @property + def time(self): + """ + Time value of this clock. + """ + if self._split is None: + return self._time + self._elapsed + else: + return self._split + + @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 'SimulatedClock[{:.2f},x{},{}]'.format( + self.time, + self._speed, + '>' if self._play else '=', + ) + + +class WallClock(BaseClock): + """ + A clock that follows wall-clock time. + """ + def __init__(self): + self._split = None + + def split(self): + self._split = time() + + def unsplit(self): + self._split = None + + @property + def time(self): + if self._split is None: + return time() + else: + return self._split + + def __repr__(self): + return 'WallClock[{}]'.format(self.time) \ No newline at end of file diff --git a/sismic/interpreter/__init__.py b/sismic/interpreter/__init__.py index e0b5f07..efe4433 100644 --- a/sismic/interpreter/__init__.py +++ b/sismic/interpreter/__init__.py @@ -1,5 +1,4 @@ from .default import Interpreter -from .clock import Clock from ..model.events import Event, InternalEvent, MetaEvent -__all__ = ['Interpreter', 'Clock', 'Event', 'InternalEvent', 'MetaEvent'] +__all__ = ['Interpreter', 'Event', 'InternalEvent', 'MetaEvent'] diff --git a/sismic/interpreter/clock.py b/sismic/interpreter/clock.py deleted file mode 100644 index ae26238..0000000 --- a/sismic/interpreter/clock.py +++ /dev/null @@ -1,89 +0,0 @@ -from time import time - - -class Clock: - """ - A basic implementation of a clock to represent both simulated and real time. - - The current time of the clock is exposed through the time attribute. - - By default, the clock follows simulated time. Methods start() and stop() can - be used to "synchronize" the clock based on real time. 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. - - The clock can also be manually adjusted by setting its time attribute. - """ - 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 real time clock. - """ - 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 'Clock[{:.2f},x{},{}]'.format( - self.time, - self._speed, - '>' if self._play else '=', - ) - - \ No newline at end of file diff --git a/sismic/interpreter/default.py b/sismic/interpreter/default.py index 408774a..504ddc0 100644 --- a/sismic/interpreter/default.py +++ b/sismic/interpreter/default.py @@ -10,7 +10,7 @@ except ImportError: pass -from .clock import Clock +from ..clock import BaseClock, SimulatedClock from ..model import ( MacroStep, MicroStep, Event, InternalEvent, MetaEvent, Statechart, Transition, @@ -42,16 +42,19 @@ class Interpreter: :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. + 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: BaseClock=None, ignore_contract: bool=False) -> None: # Internal variables self._ignore_contract = ignore_contract @@ -60,7 +63,7 @@ def __init__(self, statechart: Statechart, *, self._initialized = False # Internal clock - self.clock = Clock() + self.clock = SimulatedClock() if clock is None else clock # History states memory self._memory = {} # type: Dict[str, Optional[List[str]]] @@ -97,7 +100,7 @@ def time(self) -> float: @time.setter def time(self, value: float): warnings.warn('Interpreter.time is deprecated since 1.3.0, use Interpreter.clock.time instead', DeprecationWarning) - self.clock.time = value + self.clock.time = value # type: ignore @property def configuration(self) -> List[str]: @@ -233,12 +236,16 @@ def execute_once(self) -> Optional[MacroStep]: :return: a macro step or *None* if nothing happened """ + # Freeze clock to have a consistent time value during the step + self.clock.split() + # Compute steps computed_steps = self._compute_steps() if computed_steps is None: # No step (no transition, no event). However, check properties self._check_properties(None) + self.clock.unsplit() return None # Notify properties @@ -270,6 +277,9 @@ def execute_once(self) -> Optional[MacroStep]: self._notify_properties('step ended') self._check_properties(macro_step) + # Unfreeze time + self.clock.unsplit() + return macro_step def _raise_event(self, event: Event) -> None: diff --git a/tests/test_clock.py b/tests/test_clock.py index cc88759..e8ae8f9 100644 --- a/tests/test_clock.py +++ b/tests/test_clock.py @@ -1,13 +1,13 @@ import pytest from time import sleep -from sismic.interpreter import Clock +from sismic.clock import SimulatedClock, WallClock -class TestClock: +class TestSimulatedClock: @pytest.fixture() def clock(self): - return Clock() + return SimulatedClock() def test_initial_value(self, clock): assert clock.time == 0 @@ -68,4 +68,42 @@ def test_start_stop(self, clock): assert 0.2 <= clock.time < 0.3 - \ No newline at end of file + def test_split(self, clock): + clock.split() + clock.time = 10 + assert clock.time == 0 + clock.unsplit() + assert clock.time == 10 + + def test_split_with_automatic(self, clock): + clock.start() + sleep(0.1) + assert clock.time >= 0.1 + + clock.split() + current_time = clock.time + sleep(0.1) + assert clock.time == current_time + + clock.unsplit() + assert clock.time > current_time + + +class TestWallClock: + @pytest.fixture() + def clock(self): + return WallClock() + + def test_increase(self, clock): + current_time = clock.time + sleep(0.1) + assert clock.time > current_time + + def test_split(self, clock): + clock.split() + current_time = clock.time + sleep(0.1) + assert clock.time == current_time + clock.unsplit() + assert clock.time > current_time + \ No newline at end of file From d63d2afd8beb11a1de012c9a5a6e42306e9eb06d Mon Sep 17 00:00:00 2001 From: Alexandre Decan Date: Wed, 27 Jun 2018 14:38:00 +0200 Subject: [PATCH 3/6] Fix invalid split for WallClock (when a single clock is shared between multiple interpreters) --- sismic/clock/clock.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sismic/clock/clock.py b/sismic/clock/clock.py index d3dcf7b..e400a6f 100644 --- a/sismic/clock/clock.py +++ b/sismic/clock/clock.py @@ -146,7 +146,7 @@ def __init__(self): self._split = None def split(self): - self._split = time() + self._split = self.time def unsplit(self): self._split = None From 948dac2508ebbcbd34f8a28b368cfd591631d993 Mon Sep 17 00:00:00 2001 From: Alexandre Decan Date: Wed, 27 Jun 2018 15:44:42 +0200 Subject: [PATCH 4/6] Manual "freeze" of time, to avoid race conditions --- CHANGELOG.rst | 8 ++-- docs/time.rst | 28 +++++++++++--- sismic/clock/__init__.py | 4 +- sismic/clock/clock.py | 70 ++++++++++++----------------------- sismic/code/python.py | 14 +++---- sismic/interpreter/default.py | 24 +++++------- tests/test_bdd.py | 2 +- tests/test_clock.py | 56 ++++++++++++++-------------- tests/test_interpreter.py | 20 ++++------ tests/test_property.py | 4 +- 10 files changed, 108 insertions(+), 122 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 90b8ec5..5a0e468 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -7,19 +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 ``BaseClock`` base class and two direct implementations, - namely ``SimulatedClock`` and ``WallClock``. A ``SimulatedClock`` allows to manually or automatically + - (Added) A ``sismic.clock`` module with a ``BaseClock`` base class and three direct implementations, + namely ``SimulatedClock``, ``WallClock`` 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 a ``WallClock`` 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) ``Interpreter.time`` is deprecated, use ``Interpreter.clock.time`` instead. + - (Deprecated) Setting ``Interpreter.time`` is deprecated, set time with ``Interpreter.clock.time`` instead. 1.2.2 (2018-06-21) diff --git a/docs/time.rst b/docs/time.rst index 0763cb3..962b738 100644 --- a/docs/time.rst +++ b/docs/time.rst @@ -15,10 +15,14 @@ evaluate to ``True`` if the current time of the interpreter is at least ``x`` se 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`` seconds. -Sismic provides two implementations of :py:class:`~sismic.clock.BaseClock` in its :py:mod:`sismic.clock` module. +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.BaseClock` 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.WallClock` with -no flourish. +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.BaseClock` @@ -121,7 +125,7 @@ than real time (e.g. 2 times faster), while a lower value slows down the clock. Example: manual time ~~~~~~~~~~~~~~~~~~~~ -The following example illustrates a statechart modeling the behavior of a simple *elevator*. +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. @@ -268,12 +272,26 @@ is synchronized with system time (it relies on the ``time.time()`` function of P assert (time() - clock.time) <= 1 +Synchronized clock +------------------ + +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. + +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. + + Implementing other clocks ------------------------- You can quite easily write your own clock implementation, for example if you need to -synchronize different distributed interpreters or if you want your clock to ignore -Sismic processing time. Simply subclass the :py:class:`~sismic.clock.BaseClock` base class. +synchronize different distributed interpreters. +Simply subclass the :py:class:`~sismic.clock.BaseClock` base class. .. autoclass:: sismic.clock.BaseClock :members: diff --git a/sismic/clock/__init__.py b/sismic/clock/__init__.py index 5034aaa..fbe8e26 100644 --- a/sismic/clock/__init__.py +++ b/sismic/clock/__init__.py @@ -1,3 +1,3 @@ -from .clock import BaseClock, SimulatedClock, WallClock +from .clock import BaseClock, SimulatedClock, WallClock, SynchronizedClock -__all__ = ['BaseClock', 'SimulatedClock', 'WallClock'] \ No newline at end of file +__all__ = ['BaseClock', 'SimulatedClock', 'WallClock', 'SynchronizedClock'] \ No newline at end of file diff --git a/sismic/clock/clock.py b/sismic/clock/clock.py index e400a6f..afbb943 100644 --- a/sismic/clock/clock.py +++ b/sismic/clock/clock.py @@ -3,7 +3,7 @@ from time import time -__all__ = ['BaseClock', 'SimulatedClock', 'WallClock'] +__all__ = ['BaseClock', 'SimulatedClock', 'WallClock', 'SynchronizedClock'] class BaseClock(metaclass=abc.ABCMeta): @@ -12,10 +12,6 @@ class BaseClock(metaclass=abc.ABCMeta): 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. - - There are two important properties that must be satisfied by any - implementation: (1) time is expected to be monotonic; and (2) returned time - is expected to remain constant between calls to split() and unsplit(). """ @abc.abstractproperty def time(self): @@ -24,20 +20,6 @@ def time(self): """ raise NotImplementedError() - @abc.abstractmethod - def split(self): - """ - Freeze current time until unsplit() is called. - """ - raise NotImplementedError() - - @abc.abstractmethod - def unsplit(self): - """ - Unfreeze current time. - """ - raise NotImplementedError() - class SimulatedClock(BaseClock): """ @@ -55,7 +37,6 @@ def __init__(self): self._time = 0 self._play = False self._speed = 1 - self._split = None @property def _elapsed(self): @@ -91,27 +72,12 @@ def speed(self, speed): self._base = time() self._speed = speed - def split(self): - """ - Freeze current time until unsplit is called. - """ - self._split = self.time - - def unsplit(self): - """ - Unfreeze current time. - """ - self._split = None - @property def time(self): """ Time value of this clock. """ - if self._split is None: - return self._time + self._elapsed - else: - return self._split + return self._time + self._elapsed @time.setter def time(self, new_time): @@ -142,21 +108,31 @@ class WallClock(BaseClock): """ A clock that follows wall-clock time. """ - def __init__(self): - self._split = None + + @property + def time(self): + return time() + + def __repr__(self): + return 'WallClock[{}]'.format(self.time) + - def split(self): - self._split = self.time +class SynchronizedClock(BaseClock): + """ + 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. - def unsplit(self): - self._split = None + :param interpreter: an interpreter instance + """ + def __init__(self, interpreter): + self._interpreter = interpreter @property def time(self): - if self._split is None: - return time() - else: - return self._split + return self._interpreter.time def __repr__(self): - return 'WallClock[{}]'.format(self.time) \ No newline at end of file + return 'SynchronizedClock[{}]'.format(self._interpreter.time) \ No newline at end of file diff --git a/sismic/code/python.py b/sismic/code/python.py index c8e0925..68efa6e 100644 --- a/sismic/code/python.py +++ b/sismic/code/python.py @@ -167,7 +167,7 @@ def _after(self, name: str, seconds: float) -> bool: :param seconds: elapsed time :return: True if given state was entered more than *seconds* ago. """ - return self._interpreter.clock.time - seconds >= self._entry_time[name] + return self._interpreter.time - seconds >= self._entry_time[name] def _idle(self, name: str, seconds: float) -> bool: """ @@ -177,7 +177,7 @@ def _idle(self, name: str, seconds: float) -> bool: :param seconds: elapsed time :return: True if given state was the target of a transition more than *seconds* ago. """ - return self._interpreter.clock.time - seconds >= self._idle_time[name] + return self._interpreter.time - seconds >= self._idle_time[name] def _evaluate_code(self, code: Optional[str], *, additional_context: Mapping=None) -> bool: """ @@ -196,7 +196,7 @@ def _evaluate_code(self, code: Optional[str], *, additional_context: Mapping=Non exposed_context = { 'active': self._active, - 'time': self._interpreter.clock.time, + 'time': self._interpreter.time, } exposed_context.update(additional_context if additional_context is not None else {}) @@ -227,7 +227,7 @@ def _execute_code(self, code: Optional[str], *, additional_context: Mapping=None 'active': self._active, 'send': create_send_function(sent_events, InternalEvent), 'notify': create_send_function(sent_events, MetaEvent), - 'time': self._interpreter.clock.time, + 'time': self._interpreter.time, } exposed_context.update(additional_context if additional_context is not None else {}) @@ -275,7 +275,7 @@ def execute_action(self, transition: Transition, event: Optional[Event]=None) -> """ execution = self._execute_code(getattr(transition, 'action', None), additional_context={'event': event}) - self._idle_time[transition.source] = self._interpreter.clock.time + self._idle_time[transition.source] = self._interpreter.time return execution @@ -289,8 +289,8 @@ def execute_on_entry(self, state: StateMixin) -> List[Event]: """ execution = self._execute_code(getattr(state, 'on_entry', None)) - self._entry_time[state.name] = self._interpreter.clock.time - self._idle_time[state.name] = self._interpreter.clock.time + self._entry_time[state.name] = self._interpreter.time + self._idle_time[state.name] = self._interpreter.time return execution diff --git a/sismic/interpreter/default.py b/sismic/interpreter/default.py index 504ddc0..f8a0d8d 100644 --- a/sismic/interpreter/default.py +++ b/sismic/interpreter/default.py @@ -10,7 +10,7 @@ except ImportError: pass -from ..clock import BaseClock, SimulatedClock +from ..clock import BaseClock, SimulatedClock, SynchronizedClock from ..model import ( MacroStep, MicroStep, Event, InternalEvent, MetaEvent, Statechart, Transition, @@ -41,7 +41,7 @@ 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 + :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. @@ -64,6 +64,7 @@ def __init__(self, statechart: Statechart, *, # Internal clock 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]]] @@ -90,12 +91,9 @@ def __init__(self, statechart: Statechart, *, @property def time(self) -> float: """ - Time value (in seconds) of the internal clock. - - Deprecated since 1.3.0, use Interpreter.clock.time instead. + Time of the latest execution. """ - warnings.warn('Interpreter.time is deprecated since 1.3.0, use Interpreter.clock.time instead', DeprecationWarning) - return self.clock.time + return self._time @time.setter def time(self, value: float): @@ -177,7 +175,7 @@ def bind_property_statechart(self, statechart_or_interpreter: Union[Statechart, interpreter = statechart_or_interpreter # Sync clock - interpreter.clock = self.clock + interpreter.clock = SynchronizedClock(self) # Add to the list of properties self._bound_properties.append(interpreter) @@ -236,8 +234,8 @@ def execute_once(self) -> Optional[MacroStep]: :return: a macro step or *None* if nothing happened """ - # Freeze clock to have a consistent time value during the step - self.clock.split() + # Store time to have a consistent time value during this step + self._time = self.clock.time # Compute steps computed_steps = self._compute_steps() @@ -245,7 +243,6 @@ def execute_once(self) -> Optional[MacroStep]: if computed_steps is None: # No step (no transition, no event). However, check properties self._check_properties(None) - self.clock.unsplit() return None # Notify properties @@ -265,7 +262,7 @@ def execute_once(self) -> Optional[MacroStep]: executed_steps.append(self._apply_step(step)) executed_steps.extend(self._stabilize()) - macro_step = MacroStep(time=self.clock.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 @@ -277,9 +274,6 @@ def execute_once(self) -> Optional[MacroStep]: self._notify_properties('step ended') self._check_properties(macro_step) - # Unfreeze time - self.clock.unsplit() - return macro_step def _raise_event(self, event: Event) -> None: diff --git a/tests/test_bdd.py b/tests/test_bdd.py index 429df6b..ab401c4 100644 --- a/tests/test_bdd.py +++ b/tests/test_bdd.py @@ -115,7 +115,7 @@ def test_wait(self, context): context.interpreter.clock.time = 0 steps.wait(context, 3) assert context.interpreter.clock.time == 3 - + steps.wait(context, 6) assert context.interpreter.clock.time == 9 diff --git a/tests/test_clock.py b/tests/test_clock.py index e8ae8f9..5ecf9c5 100644 --- a/tests/test_clock.py +++ b/tests/test_clock.py @@ -1,7 +1,7 @@ import pytest from time import sleep -from sismic.clock import SimulatedClock, WallClock +from sismic.clock import SimulatedClock, WallClock, SynchronizedClock class TestSimulatedClock: @@ -16,6 +16,11 @@ 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) @@ -68,26 +73,6 @@ def test_start_stop(self, clock): assert 0.2 <= clock.time < 0.3 - def test_split(self, clock): - clock.split() - clock.time = 10 - assert clock.time == 0 - clock.unsplit() - assert clock.time == 10 - - def test_split_with_automatic(self, clock): - clock.start() - sleep(0.1) - assert clock.time >= 0.1 - - clock.split() - current_time = clock.time - sleep(0.1) - assert clock.time == current_time - - clock.unsplit() - assert clock.time > current_time - class TestWallClock: @pytest.fixture() @@ -99,11 +84,26 @@ def test_increase(self, clock): sleep(0.1) assert clock.time > current_time - def test_split(self, clock): - clock.split() - current_time = clock.time - sleep(0.1) - assert clock.time == current_time - clock.unsplit() - 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_interpreter.py b/tests/test_interpreter.py index 9f7ed4f..614dfad 100644 --- a/tests/test_interpreter.py +++ b/tests/test_interpreter.py @@ -26,24 +26,20 @@ def test_init(self, interpreter): assert not interpreter.final def test_time(self, interpreter): - assert interpreter.clock.time == 0 + assert interpreter.time == 0 interpreter.clock.time += 10 - assert interpreter.clock.time == 10 + # No execution means no new time value + assert interpreter.time == 0 - interpreter.clock.time = 20 - assert interpreter.clock.time == 20 - - with pytest.raises(ValueError): - interpreter.clock.time = 10 + interpreter.execute() + assert interpreter.time == 10 def test_deprecated_time(self, interpreter): - with pytest.warns(DeprecationWarning): - assert interpreter.time == 0 - with pytest.warns(DeprecationWarning): interpreter.time += 1 - assert interpreter.time == 1 + assert interpreter.time == 0 + assert interpreter.clock.time == 1 def test_queue(self, interpreter): interpreter.queue(Event('e1')) @@ -539,7 +535,7 @@ def test_run_in_background(elevator): task.stop() assert elevator.context['current'] == 4 - assert elevator.clock.time == 0 + assert elevator.time == 0 class TestCoverageFromTrace: diff --git a/tests/test_property.py b/tests/test_property.py index f23caf6..c7d78cf 100644 --- a/tests/test_property.py +++ b/tests/test_property.py @@ -20,9 +20,9 @@ def property_statechart(self, microwave, mocker): return property def test_synchronised_time(self, microwave, property_statechart): - assert microwave.clock.time == property_statechart.clock.time + assert microwave.time == property_statechart.time microwave.clock.time += 10 - assert microwave.clock.time == property_statechart.clock.time + assert microwave.time == property_statechart.time def test_empty_step(self, microwave, property_statechart): microwave.execute() From 98065c27dfdbfef27209db30722d2031bbaa70d9 Mon Sep 17 00:00:00 2001 From: Alexandre Decan Date: Thu, 28 Jun 2018 10:41:55 +0200 Subject: [PATCH 5/6] Rename WallClock to UtcClock --- CHANGELOG.rst | 4 ++-- docs/time.rst | 10 +++++----- sismic/clock/__init__.py | 4 ++-- sismic/clock/clock.py | 23 ++++++++++++----------- tests/test_clock.py | 6 +++--- 5 files changed, 24 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 5a0e468..856ce30 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -8,7 +8,7 @@ Unreleased 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 ``BaseClock`` base class and three direct implementations, - namely ``SimulatedClock``, ``WallClock`` and ``SynchronizedClock``. A ``SimulatedClock`` allows to manually or automatically + 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. @@ -16,7 +16,7 @@ Unreleased - (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 a ``WallClock`` instance instead. + 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 diff --git a/docs/time.rst b/docs/time.rst index 962b738..8e9863f 100644 --- a/docs/time.rst +++ b/docs/time.rst @@ -20,8 +20,8 @@ The value of that attribute is computed at the beginning of each executed step b Sismic provides three implementations of :py:class:`~sismic.clock.BaseClock` 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.WallClock` with -no flourish. The third implemention is a :py:class:`~sismic.clock.SynchronizedClock` that synchronizes its time value +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 @@ -259,16 +259,16 @@ We can now check that our elevator is on the ground floor: Wall-clock ---------- -The second clock provided by Sismic is a :py:class:`~sismic.clock.WallClock` whose 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). .. testcode:: - from sismic.clock import WallClock + from sismic.clock import UtcClock from time import time - clock = WallClock() + clock = UtcClock() assert (time() - clock.time) <= 1 diff --git a/sismic/clock/__init__.py b/sismic/clock/__init__.py index fbe8e26..7ce59d1 100644 --- a/sismic/clock/__init__.py +++ b/sismic/clock/__init__.py @@ -1,3 +1,3 @@ -from .clock import BaseClock, SimulatedClock, WallClock, SynchronizedClock +from .clock import BaseClock, SimulatedClock, UtcClock, SynchronizedClock -__all__ = ['BaseClock', 'SimulatedClock', 'WallClock', 'SynchronizedClock'] \ No newline at end of file +__all__ = ['BaseClock', 'SimulatedClock', 'UtcClock', 'SynchronizedClock'] \ No newline at end of file diff --git a/sismic/clock/clock.py b/sismic/clock/clock.py index afbb943..e1ccf73 100644 --- a/sismic/clock/clock.py +++ b/sismic/clock/clock.py @@ -1,9 +1,10 @@ import abc +from numbers import Number from time import time -__all__ = ['BaseClock', 'SimulatedClock', 'WallClock', 'SynchronizedClock'] +__all__ = ['BaseClock', 'SimulatedClock', 'UtcClock', 'SynchronizedClock'] class BaseClock(metaclass=abc.ABCMeta): @@ -14,12 +15,15 @@ class BaseClock(metaclass=abc.ABCMeta): to get the current time during the execution of a statechart. """ @abc.abstractproperty - def time(self): + def time(self) -> Number: """ Current time """ raise NotImplementedError() + def __repr__(self): + return '{}[{}]'.format(self.__class__.__name__, self.time) + class SimulatedClock(BaseClock): """ @@ -97,24 +101,24 @@ def __str__(self): return '{:.2f}'.format(float(self.time)) def __repr__(self): - return 'SimulatedClock[{:.2f},x{},{}]'.format( + return '{}[{:.2f},x{},{}]'.format( + self.__class__.__name__, self.time, self._speed, '>' if self._play else '=', ) -class WallClock(BaseClock): +class UtcClock(BaseClock): """ - A clock that follows wall-clock time. + 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() - - def __repr__(self): - return 'WallClock[{}]'.format(self.time) class SynchronizedClock(BaseClock): @@ -133,6 +137,3 @@ def __init__(self, interpreter): @property def time(self): return self._interpreter.time - - def __repr__(self): - return 'SynchronizedClock[{}]'.format(self._interpreter.time) \ No newline at end of file diff --git a/tests/test_clock.py b/tests/test_clock.py index 5ecf9c5..6610a82 100644 --- a/tests/test_clock.py +++ b/tests/test_clock.py @@ -1,7 +1,7 @@ import pytest from time import sleep -from sismic.clock import SimulatedClock, WallClock, SynchronizedClock +from sismic.clock import SimulatedClock, UtcClock, SynchronizedClock class TestSimulatedClock: @@ -74,10 +74,10 @@ def test_start_stop(self, clock): assert 0.2 <= clock.time < 0.3 -class TestWallClock: +class TestUtcClock: @pytest.fixture() def clock(self): - return WallClock() + return UtcClock() def test_increase(self, clock): current_time = clock.time From 7d4cc032522b2fab78d40fa69d50b00b1084a3f6 Mon Sep 17 00:00:00 2001 From: Alexandre Decan Date: Thu, 28 Jun 2018 13:56:49 +0200 Subject: [PATCH 6/6] Rename BaseClock to Clock --- CHANGELOG.rst | 2 +- docs/time.rst | 12 ++++++------ sismic/clock/__init__.py | 4 ++-- sismic/clock/clock.py | 10 +++++----- sismic/helpers.py | 1 - sismic/interpreter/default.py | 7 ++++--- 6 files changed, 18 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 856ce30..9a6f8c2 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -7,7 +7,7 @@ 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 ``BaseClock`` base class and three direct implementations, + - (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. diff --git a/docs/time.rst b/docs/time.rst index 8e9863f..5fcb477 100644 --- a/docs/time.rst +++ b/docs/time.rst @@ -18,14 +18,14 @@ Similarly, ``idle(x)`` evaluates to ``True`` if no transition was triggered duri 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.BaseClock` in its :py:mod:`sismic.clock` module. +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.BaseClock` +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. @@ -101,8 +101,8 @@ new value for its :py:attr:`~sismic.clock.SimulatedClock.time` attribute: after 0.1: 10.1 -Finally, a clock based on real time can be accelerated or slowed down by changing the value -of its :py:attr:`~sismic.clock.BaseClock.speed` attribute. By default, the value of this +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. @@ -291,9 +291,9 @@ Implementing other clocks 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.BaseClock` base class. +Simply subclass the :py:class:`~sismic.clock.Clock` base class. -.. autoclass:: sismic.clock.BaseClock +.. autoclass:: sismic.clock.Clock :members: :member-order: bysource :noindex: diff --git a/sismic/clock/__init__.py b/sismic/clock/__init__.py index 7ce59d1..12c8969 100644 --- a/sismic/clock/__init__.py +++ b/sismic/clock/__init__.py @@ -1,3 +1,3 @@ -from .clock import BaseClock, SimulatedClock, UtcClock, SynchronizedClock +from .clock import Clock, SimulatedClock, UtcClock, SynchronizedClock -__all__ = ['BaseClock', 'SimulatedClock', 'UtcClock', 'SynchronizedClock'] \ No newline at end of file +__all__ = ['Clock', 'SimulatedClock', 'UtcClock', 'SynchronizedClock'] \ No newline at end of file diff --git a/sismic/clock/clock.py b/sismic/clock/clock.py index e1ccf73..9d6b09b 100644 --- a/sismic/clock/clock.py +++ b/sismic/clock/clock.py @@ -4,10 +4,10 @@ from time import time -__all__ = ['BaseClock', 'SimulatedClock', 'UtcClock', 'SynchronizedClock'] +__all__ = ['Clock', 'SimulatedClock', 'UtcClock', 'SynchronizedClock'] -class BaseClock(metaclass=abc.ABCMeta): +class Clock(metaclass=abc.ABCMeta): """ Abstract implementation of a clock, as used by an interpreter. @@ -25,7 +25,7 @@ def __repr__(self): return '{}[{}]'.format(self.__class__.__name__, self.time) -class SimulatedClock(BaseClock): +class SimulatedClock(Clock): """ A simulated clock, starting from 0, that can be manually or automatically incremented. @@ -109,7 +109,7 @@ def __repr__(self): ) -class UtcClock(BaseClock): +class UtcClock(Clock): """ A clock that simulates a wall clock in UTC. @@ -121,7 +121,7 @@ def time(self): return time() -class SynchronizedClock(BaseClock): +class SynchronizedClock(Clock): """ A clock that is synchronized with a given interpreter. diff --git a/sismic/helpers.py b/sismic/helpers.py index 6dba8ca..9ff8179 100644 --- a/sismic/helpers.py +++ b/sismic/helpers.py @@ -48,7 +48,6 @@ def run_in_background(interpreter: Interpreter, import time def _task(): - starttime = time.time() while not interpreter.final: steps = interpreter.execute() if callback: diff --git a/sismic/interpreter/default.py b/sismic/interpreter/default.py index f8a0d8d..c30443d 100644 --- a/sismic/interpreter/default.py +++ b/sismic/interpreter/default.py @@ -1,5 +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 @@ -10,7 +11,7 @@ except ImportError: pass -from ..clock import BaseClock, SimulatedClock, SynchronizedClock +from ..clock import Clock, SimulatedClock, SynchronizedClock from ..model import ( MacroStep, MicroStep, Event, InternalEvent, MetaEvent, Statechart, Transition, @@ -54,7 +55,7 @@ class Interpreter: def __init__(self, statechart: Statechart, *, evaluator_klass: Callable[..., Evaluator]=PythonEvaluator, initial_context: Mapping[str, Any]=None, - clock: BaseClock=None, + clock: Clock=None, ignore_contract: bool=False) -> None: # Internal variables self._ignore_contract = ignore_contract @@ -89,7 +90,7 @@ def __init__(self, statechart: Statechart, *, self._evaluator.execute_statechart(statechart) @property - def time(self) -> float: + def time(self) -> Number: """ Time of the latest execution. """