From fbf0c31a7b8098c468d82fdc19a9e5fe9f553f02 Mon Sep 17 00:00:00 2001 From: Antoine Weisrock Date: Thu, 14 Nov 2024 23:10:30 +0100 Subject: [PATCH 1/4] feat: add Pause Block for pausing test, modify master Block accordingly --- src/crappy/blocks/__init__.py | 1 + src/crappy/blocks/meta_block/block.py | 16 ++- src/crappy/blocks/pause.py | 187 ++++++++++++++++++++++++++ 3 files changed, 202 insertions(+), 2 deletions(-) create mode 100644 src/crappy/blocks/pause.py diff --git a/src/crappy/blocks/__init__.py b/src/crappy/blocks/__init__.py index 76883864..c78a3e1e 100644 --- a/src/crappy/blocks/__init__.py +++ b/src/crappy/blocks/__init__.py @@ -19,6 +19,7 @@ from .machine import Machine from .mean import MeanBlock from .multiplexer import Multiplexer +from .pause import Pause from .pid import PID from .link_reader import LinkReader from .recorder import Recorder diff --git a/src/crappy/blocks/meta_block/block.py b/src/crappy/blocks/meta_block/block.py index 54d38977..ee0e08c3 100644 --- a/src/crappy/blocks/meta_block/block.py +++ b/src/crappy/blocks/meta_block/block.py @@ -52,6 +52,7 @@ class Block(Process, metaclass=MetaBlock): shared_t0: Optional[Synchronized] = None ready_barrier: Optional[synchronize.Barrier] = None start_event: Optional[synchronize.Event] = None + pause_event: Optional[synchronize.Event] = None stop_event: Optional[synchronize.Event] = None raise_event: Optional[synchronize.Event] = None kbi_event: Optional[synchronize.Event] = None @@ -79,11 +80,13 @@ def __init__(self) -> None: self.freq = None self.display_freq = False self.name = self.get_name(type(self).__name__) + self.pausable: bool = True # The synchronization objects will be set later self._instance_t0: Optional[Synchronized] = None self._ready_barrier: Optional[synchronize.Barrier] = None self._start_event: Optional[synchronize.Event] = None + self._pause_event: Optional[synchronize.Event] = None self._stop_event: Optional[synchronize.Event] = None self._raise_event: Optional[synchronize.Event] = None self._kbi_event: Optional[synchronize.Event] = None @@ -239,6 +242,7 @@ def prepare_all(cls, log_level: Optional[int] = logging.DEBUG) -> None: cls.ready_barrier = Barrier(len(cls.instances) + 1) cls.shared_t0 = Value('d', -1.0) cls.start_event = Event() + cls.pause_event = Event() cls.stop_event = Event() cls.raise_event = Event() cls.kbi_event = Event() @@ -266,6 +270,7 @@ def prepare_all(cls, log_level: Optional[int] = logging.DEBUG) -> None: instance._ready_barrier = cls.ready_barrier instance._instance_t0 = cls.shared_t0 instance._stop_event = cls.stop_event + instance._pause_event = cls.pause_event instance._start_event = cls.start_event instance._raise_event = cls.raise_event instance._kbi_event = cls.kbi_event @@ -736,6 +741,7 @@ def reset(cls) -> None: cls.shared_t0 = None cls.ready_barrier = None cls.start_event = None + cls.pause_event = None cls.stop_event = None cls.raise_event = None cls.kbi_event = None @@ -947,8 +953,14 @@ def main(self) -> None: # Looping until told to stop or an error occurs while not self._stop_event.is_set(): - self.log(logging.DEBUG, "Looping") - self.loop() + # Only looping if the Block is not paused + if not self._pause_event.is_set() or not self.pausable: + self.log(logging.DEBUG, "Looping") + self.loop() + else: + self.log(logging.DEBUG, "Block currently paused, not calling loop()") + # Handling the frequency in all cases to avoid hyperactive Blocks when + # "paused" self.log(logging.DEBUG, "Handling freq") self._handle_freq() diff --git a/src/crappy/blocks/pause.py b/src/crappy/blocks/pause.py new file mode 100644 index 00000000..61f247fa --- /dev/null +++ b/src/crappy/blocks/pause.py @@ -0,0 +1,187 @@ +# coding: utf-8 + +from typing import Optional, Union +from collections.abc import Iterable, Callable +import logging +from re import split +from time import time + +from .meta_block import Block + + +class Pause(Block): + """This Block parses the data it receives and checks if this data meets the + given pause criteria. If so, the other Blocks are paused until the criteria + are no longer met. + + When paused, the other Blocks are still looping but no longer executing any + code. This feature is mostly useful when human intervention on a test setup + is required, to ensure that nothing happens during that time. + + It is possible to prevent a Block from being affected by a pause by setting + its ``pausable`` attribute to :obj:`False`. In particular, the Block(s) + responsible for outputting the labels checked by the criteria should keep + running, otherwise the test will be put on hold forever. + + Important: + This Block prevents other Blocks from running normally, but no specific + mechanism for putting hardware in an idle state is implemented. For + example, a motor driven by an :class:`~crappy.blocks.Actuator` Block might + keep moving according to the last command it received before the Blocks + were paused. It is up to the user to put hardware in the desired state + before starting a pause. + + Warning: + Using this Block is potentially dangerous, as it leaves hardware + unsupervised with no software control on it. It is advised to always + include hardware securities on your setup. + + .. versionadded:: 2.0.7 + """ + + def __init__(self, + criteria: Union[str, Callable, Iterable[Union[str, Callable]]], + freq: Optional[float] = 50, + display_freq: bool = False, + debug: Optional[bool] = False) -> None: + """Sets the arguments and initializes the parent class. + + Args: + criteria: A :obj:`str`, a :obj:`~collections.abc.Callable`, or an + :obj:`~collections.abc.Iterable` (like a :obj:`tuple` or a :obj:`list`) + containing such objects. Each :obj:`str` or + :obj:`~collections.abc.Callable` represents one pause criterion. There + is no limit to the given number of stop criteria. If a criterion is + given as an :obj:`~collections.abc.Callable`, it should accept as its + sole argument the output of the + :meth:`crappy.blocks.Block.recv_all_data` method and return :obj:`True` + if the criterion is met, and :obj:`False` otherwise. If the criterion + is given as a :obj:`str`, it should follow one the following syntaxes : + :: + + ' > ' + ' < ' + + With ```` and ```` to be replaced respectively with the + name of a received label and a threshold value. The spaces in the + string are ignored. + freq: The target looping frequency for the Block. If :obj:`None`, loops + as fast as possible. + display_freq: If :obj:`True`, displays the looping frequency of the + Block. + debug: If :obj:`True`, displays all the log messages including the + :obj:`~logging.DEBUG` ones. If :obj:`False`, only displays the log + messages with :obj:`~logging.INFO` level or higher. If :obj:`None`, + disables logging for this Block. + """ + + super().__init__() + + self.pausable = False + self.freq = freq + self.display_freq = display_freq + self.debug = debug + + # Handling the case when only one stop condition is given + if isinstance(criteria, str) or isinstance(criteria, Callable): + criteria = (criteria,) + criteria = tuple(criteria) + + self._raw_crit: tuple[Union[str, + Callable[[dict[str, list]], bool]]] = criteria + self._criteria: Optional[tuple[Callable[[dict[str, list]], bool]]] = None + + def prepare(self) -> None: + """Converts all the given criteria to :obj:`~collections.abc.Callable`.""" + + # This operation cannot be performed during __init__ due to limitations of + # the spawn start method of multiprocessing + self._criteria = tuple(map(self._parse_criterion, self._raw_crit)) + + def loop(self) -> None: + """Receives data from upstream Blocks, checks if this data meets at least + one criterion, and puts the other Blocks in pause if that's the case.""" + + if not (data := self.recv_all_data()): + self.log(logging.DEBUG, "No data received during this loop") + return + + # Pausing only if not paused, and stop criterion is met + if (self._criteria and any(crit(data) for crit in self._criteria) + and not self._pause_event.is_set()): + self.log(logging.WARNING, "Stop criterion reached, pausing the Blocks !") + self._pause_event.set() + return + + if (self._criteria and not any(crit(data) for crit in self._criteria) + and self._pause_event.is_set()): + self.log(logging.WARNING, "Stop criterion no longer satisfied, " + "un-pausing the Blocks !") + self._pause_event.clear() + return + + self.log(logging.DEBUG, "No pausing or un-pausing during this loop") + + def _parse_criterion(self, + criterion: Union[str, Callable[[dict[str, list]], bool]] + ) -> Callable[[dict[str, list]], bool]: + """Parses a Callable or string criterion given as an input by the user, and + returns the associated Callable.""" + + # If the criterion is already a callable, returning it + if isinstance(criterion, Callable): + self.log(logging.DEBUG, "Criterion is a callable") + return criterion + + # Second case, the criterion is a string containing '<' + if '<' in criterion: + self.log(logging.DEBUG, "Criterion is of type var < thresh") + var, thresh = split(r'\s*<\s*', criterion) + + # Return a function that checks if received data is inferior to threshold + def cond(data: dict[str, list]) -> bool: + """Criterion checking that the label values are below a given + threshold.""" + + if var in data: + return any((val < float(thresh) for val in data[var])) + return False + + return cond + + # Third case, the criterion is a string containing '>' + elif '>' in criterion: + self.log(logging.DEBUG, "Criterion is of type var > thresh") + var, thresh = split(r'\s*>\s*', criterion) + + # Special case for a time criterion + if var == 't(s)': + self.log(logging.DEBUG, "Criterion is about the elapsed time") + + # Return a function that checks if the given time was reached + def cond(_: dict[str, list]) -> bool: + """Criterion checking if a given delay is expired.""" + + return time() - self.t0 > float(thresh) + + return cond + + # Regular case + else: + + # Return a function that checks if received data is superior to + # threshold + def cond(data: dict[str, list]) -> bool: + """Criterion checking that the label values are above a given + threshold.""" + + if var in data: + return any((val > float(thresh) for val in data[var])) + return False + + return cond + + # Otherwise, it's an invalid syntax + else: + raise ValueError("Wrong syntax for the criterion, please refer to the " + "documentation") From 2ddb344407a3f797808ea49d3999fe0e4326424f Mon Sep 17 00:00:00 2001 From: Antoine Weisrock Date: Thu, 14 Nov 2024 23:12:51 +0100 Subject: [PATCH 2/4] docs: add documentation and example about Pause Block --- docs/source/crappy_docs/blocks.rst | 6 ++ docs/source/developers.rst | 10 ++- docs/source/features.rst | 14 ++++ docs/source/tutorials/custom_objects.rst | 4 + examples/blocks/pause_block.py | 93 ++++++++++++++++++++++++ 5 files changed, 125 insertions(+), 2 deletions(-) create mode 100644 examples/blocks/pause_block.py diff --git a/docs/source/crappy_docs/blocks.rst b/docs/source/crappy_docs/blocks.rst index 72217623..9ee2c203 100644 --- a/docs/source/crappy_docs/blocks.rst +++ b/docs/source/crappy_docs/blocks.rst @@ -120,6 +120,12 @@ Multiplexer :members: loop :special-members: __init__ +Pause Block ++++++++++++ +.. autoclass:: crappy.blocks.Pause + :members: prepare, loop + :special-members: __init__ + PID +++ .. autoclass:: crappy.blocks.PID diff --git a/docs/source/developers.rst b/docs/source/developers.rst index 15872fa7..43a21582 100644 --- a/docs/source/developers.rst +++ b/docs/source/developers.rst @@ -321,7 +321,7 @@ attributes. In addition to the synchronization and logging attributes, each instance of Block also has : - A few attributes managing its execution (target looping frequency, niceness, - flag for displaying the achieved looping frequency). + flag for displaying the achieved looping frequency, pausability). - A few buffers storing values needed for trying to achieve and displaying the looping frequency. - A name, given by a :obj:`classmethod` to ensure it is unique. @@ -446,7 +446,13 @@ specific to the first loop, and then the Block starts looping forever by calling it :meth:`~crappy.blocks.Block.main` method. Under the hood, this method calls the :meth:`~crappy.blocks.Block.loop` method, performing the main task for which the Block was written. It also handles the regulation and the -display of the looping frequency, if requested by the user. +display of the looping frequency, if requested by the user. If a +:class:`~crappy.blocks.Pause` Block is used, all the Blocks having their +``pausable`` attribute set to :obj:`True` might be paused (most Blocks by +default). When paused, the :meth:`~crappy.blocks.Block.main` method keeps +looping at its target frequency, but the :meth:`~crappy.blocks.Block.loop` +method is never called. As soon as the pause ends, the normal behavior is +restored. There are several ways the Block can stop. First, the stop :obj:`~multiprocessing.Event` might be set in another Process, which conducts diff --git a/docs/source/features.rst b/docs/source/features.rst index 95fc93d6..f6fc30a7 100644 --- a/docs/source/features.rst +++ b/docs/source/features.rst @@ -328,6 +328,20 @@ Hardware control `_. +Test management ++++++++++++++++ + +- :ref:`Pause Block` + + Pauses the current Crappy script if the received data meets one of the given + criteria. Useful when human intervention on hardware is needed during a test, + but has some strong limitations. Refer to the documentation of this Block for + more details. + + The examples folder on GitHub contains `one example of the Pause Block + `_. + Others ++++++ diff --git a/docs/source/tutorials/custom_objects.rst b/docs/source/tutorials/custom_objects.rst index 6109a5cf..32bd2fbf 100644 --- a/docs/source/tutorials/custom_objects.rst +++ b/docs/source/tutorials/custom_objects.rst @@ -945,6 +945,10 @@ meaning : - :py:`name` contains the unique name attributed to the Block by Crappy. It can be read at any time, and even modified. This name is only used for logging, and appears in the log messages for identifying where a message comes from. +- :py:`pausable` is a :obj:`bool` indicating whether the Block is affected when + a pause is started by a :class:`~crappy.blocks.Pause` Block. By default, + most Blocks are affected except for the ones managing the test flow (like the + :class:`~crappy.blocks.StopButton` Block). In the presented example, you may have recognized a few of the presented attributes. They are highlighted here for convenience : diff --git a/examples/blocks/pause_block.py b/examples/blocks/pause_block.py new file mode 100644 index 00000000..d78f9a5b --- /dev/null +++ b/examples/blocks/pause_block.py @@ -0,0 +1,93 @@ +# coding: utf-8 + +""" +This example demonstrates the use of the Pause Block. It does not require +any hardware to run, but necessitates the Python module psutil to be installed. + +This Block allows to pause other Blocks during a test based on a given set of +conditions, and to later resume the paused Blocks. This can be useful when +human intervention is needed on a setup while a test is running, to make sure +no command is sent to hardware during that time. + +Here, a Generator Block generates a signal, based on which a Pause Block +decides to pause or resume the other Blocks. The Generator is of course +configured to be insensitive to the pauses. In parallel, an IOBlock monitors +the current RAM usage, and sends it to a LinkReader Block for display. + +After starting this script, the values acquired by the IOBlock start appearing +in the console. After 8s, they should stop appearing, as the IOBlock is put in +pause. After 12s, it is resumed and the values appear again. Same goes after +28s, except the pause never ends due to a second pause condition being +satisfied for t>30s. To end this demo, click on the stop button that appears. +You can also hit CTRL+C, but it is not a clean way to stop Crappy. +""" + +import crappy + +if __name__ == '__main__': + + # This Generator Block generates a cyclic ramp signal, and that is sent to + # the Pause Block + gen = crappy.blocks.Generator( + # Generating a cyclic ramp signal, oscillating in a linear way between 0 + # and 10 with a period of 20s + ({'type': 'CyclicRamp', 'init_value': 0, 'cycles': 0, + 'condition1': 'delay=10', 'condition2': 'delay=10', + 'speed1': 1, 'speed2': -1},), + cmd_label='value', # The labels carrying the generated value + freq=10, # Setting a low frequency because we don't need more + + # Sticking to default for the other arguments + ) + # Extremely important line, prevents the Generator from being paused + # Otherwise, the signal checked by the Pause Block ceases to be generated and + # the pause therefore never ends + gen.pausable = False + + # This Block checks if any of the pause criteria are met, and if so puts all + # the pausable Blocks in pause + pause = crappy.blocks.Pause( + # The pause lasts as long as the "value" label is higher than 8, or when + # the time reaches 30s + criteria=('value>8', 't(s)>30'), + freq=20, # Setting a low frequency because we don't need more + + # Sticking to default for the other arguments + ) + + # This IOBlock reads the current memory usage of the system, and sends it to + # the LinkReader + io = crappy.blocks.IOBlock( + 'FakeInOut', # The name of the InOut object to drive + labels=('t(s)', 'memory'), # The names of the labels to output + freq=5, # Low frequency to avoid spamming the console + display_freq=True, # Display the looping frequency to show that there + # are still loops, although no data is acquired + + # Sticking to default for the other arguments + ) + + # This LinkReader Block displays in the console the data it receives from the + # IOBlock + reader = crappy.blocks.LinkReader( + name='Reader', # A name for identifying the Block in the console + freq=5, # Useless to set a frequency higher than the labels to display + + # Sticking to default for the other arguments + ) + # During the pause, no data is displayed because the IOBlock is on hold and + # not because the LinkReader is paused + reader.pausable = False + + # This Block allows the user to properly exit the script + # By default, it is not affected by the pause mechanism + stop = crappy.blocks.StopButton( + # No specific argument to give for this Block + ) + + # Linking the Block so that the information is correctly sent and received + crappy.link(gen, pause) + crappy.link(io, reader) + + # Mandatory line for starting the test, this call is blocking + crappy.start() From cd2d35bec09ebdd22af5ac00cac0f07e12fbca8b Mon Sep 17 00:00:00 2001 From: Antoine Weisrock Date: Thu, 14 Nov 2024 23:14:18 +0100 Subject: [PATCH 3/4] refactor: make some Blocks not pausable by default --- src/crappy/blocks/sink.py | 1 + src/crappy/blocks/stop_block.py | 1 + src/crappy/blocks/stop_button.py | 1 + 3 files changed, 3 insertions(+) diff --git a/src/crappy/blocks/sink.py b/src/crappy/blocks/sink.py index 7e387629..24467fa1 100644 --- a/src/crappy/blocks/sink.py +++ b/src/crappy/blocks/sink.py @@ -43,6 +43,7 @@ def __init__(self, self.display_freq = display_freq self.freq = freq self.debug = debug + self.pausable = False def loop(self) -> None: """Drops all the received data.""" diff --git a/src/crappy/blocks/stop_block.py b/src/crappy/blocks/stop_block.py index 89d377cb..e4455767 100644 --- a/src/crappy/blocks/stop_block.py +++ b/src/crappy/blocks/stop_block.py @@ -59,6 +59,7 @@ def __init__(self, self.freq = freq self.display_freq = display_freq self.debug = debug + self.pausable = False # Handling the case when only one stop condition is given if isinstance(criteria, str) or isinstance(criteria, Callable): diff --git a/src/crappy/blocks/stop_button.py b/src/crappy/blocks/stop_button.py index aae5dfcb..71c13045 100644 --- a/src/crappy/blocks/stop_button.py +++ b/src/crappy/blocks/stop_button.py @@ -45,6 +45,7 @@ def __init__(self, self.freq = freq self.display_freq = display_freq self.debug = debug + self.pausable = False self._label = None self._button = None From 9d916c28302bc6b46bd74d626c09962628dd2460 Mon Sep 17 00:00:00 2001 From: Antoine Weisrock Date: Thu, 14 Nov 2024 23:24:17 +0100 Subject: [PATCH 4/4] fix: incorrect reference in documentation of Pause Block --- src/crappy/blocks/pause.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/crappy/blocks/pause.py b/src/crappy/blocks/pause.py index 61f247fa..55702351 100644 --- a/src/crappy/blocks/pause.py +++ b/src/crappy/blocks/pause.py @@ -26,7 +26,7 @@ class Pause(Block): Important: This Block prevents other Blocks from running normally, but no specific mechanism for putting hardware in an idle state is implemented. For - example, a motor driven by an :class:`~crappy.blocks.Actuator` Block might + example, a motor driven by an :class:`~crappy.blocks.Machine` Block might keep moving according to the last command it received before the Blocks were paused. It is up to the user to put hardware in the desired state before starting a pause.