Skip to content

Commit 595df27

Browse files
authored
Merge pull request #108 from LaboratoireMecaniqueLille/feature/add_synchronizer_block
Add the `Synchronizer` Block
2 parents 4b87b72 + ddca3c8 commit 595df27

File tree

6 files changed

+337
-0
lines changed

6 files changed

+337
-0
lines changed

docs/source/crappy_docs/blocks.rst

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,12 @@ Stop Button
149149
:members: prepare, loop, finish
150150
:special-members: __init__
151151

152+
Synchronizer
153+
++++++++++++
154+
.. autoclass:: crappy.blocks.Synchronizer
155+
:members: loop
156+
:special-members: __init__
157+
152158
UController
153159
+++++++++++
154160
.. autoclass:: crappy.blocks.UController

docs/source/features.rst

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,15 @@ Data processing
121121
<https://github.com/LaboratoireMecaniqueLille/crappy/blob/master/examples/
122122
blocks/multiplexer.py>`_.
123123

124+
- :ref:`Synchronizer`
125+
126+
Allows putting labels emitted at different frequencies on the same time base
127+
as a reference label. Very similar to the :ref:`Multiplexer` Block, except
128+
the :ref:`Multiplexer` takes an independent time base for interpolation. Used
129+
when the original values of a label need to be preserved while the other
130+
labels can be interpolated, for example when the reference is the output of a
131+
low-frequency image-processing.
132+
124133
Real-time image correlation
125134
+++++++++++++++++++++++++++
126135

examples/blocks/synchronizer.py

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
# coding: utf-8
2+
3+
"""
4+
This example demonstrates the use of the Synchronizer Block. It does not
5+
require any specific hardware to run, but necessitates the matplotlib Python
6+
module to be installed.
7+
8+
The Synchronizer Block takes inputs from several Blocks, and interpolates the
9+
target labels on the timestamps of another label taken as a reference. This
10+
behavior is very similar to that of the Multiplexer Block, except the
11+
Multiplexer uses a time basis independent of the received labels. The
12+
Synchronizer is useful when signals need to be synchronized with a reference
13+
label, for example with data derived from image processing.
14+
15+
Here, the idea is to visually demonstrate how several signals with different
16+
data rates can be interpolated by the Synchronizer Block on a target time base.
17+
Two labels are generated by two Generators at different frequencies, as well as
18+
one random label generated by another Generator and considered as a reference.
19+
They are all sent to a Synchronizer for interpolation, and the interpolated
20+
data is sent to a Grapher for display.
21+
22+
After starting this script, just watch how the two interpolated labels are now
23+
synchronized on the time base of the reference labels. They are then all
24+
displayed at the same frequency by the Grapher, whereas they have originally
25+
very different frequencies. You can try to change the different frequencies and
26+
see what the result is. This demo ends after 22s. You can also hit CTRL+C to
27+
stop it earlier, but it is not a clean way to stop Crappy.
28+
"""
29+
30+
import crappy
31+
from typing import Tuple, Dict, Optional
32+
from time import sleep
33+
import random
34+
35+
36+
class RandomPath(crappy.blocks.generator_path.meta_path.Path):
37+
"""This custom Generator Path outputs a random value within given bounds, and
38+
waits for a given delay before returning this value.
39+
40+
Both the values and the timestamps are therefore random. This class is used
41+
to demonstrate that the Synchronizer works as expected in this example, and
42+
not because of some regularity in the input data.
43+
"""
44+
45+
def __init__(self,
46+
time_range: Tuple[float, float],
47+
value_range: Tuple[float, float]) -> None:
48+
"""Sets the arguments and initializes the parent class.
49+
50+
Args:
51+
time_range: The possible seconds range for sleeping before returning the
52+
generated value.
53+
value_range: The possible range for the randomly generated values.
54+
"""
55+
56+
super().__init__()
57+
58+
self._time_range: Tuple[float, float] = time_range
59+
self._value_range: Tuple[float, float] = value_range
60+
61+
def get_cmd(self, data: Dict[str, list]) -> Optional[float]:
62+
"""Returns a randomly generated value after sleeping a random number of
63+
seconds."""
64+
65+
sleep(random.uniform(*self._time_range))
66+
return random.uniform(*self._value_range)
67+
68+
69+
if __name__ == '__main__':
70+
71+
# This Generator generates a random signal considered as a reference, and
72+
# sends it to the Synchronizer Block for interpolation
73+
gen_ref = crappy.blocks.Generator(
74+
# Generating a random signal to take as a reference time base
75+
({'type': 'RandomPath', 'time_range': (0.50, 0.150),
76+
'value_range': (-1, 1)},),
77+
cmd_label='ref', # The label carrying the generated signal
78+
freq=3, # Frequency is a prime number to ensure signals from several
79+
# Blocks will be desynchronized
80+
81+
# Sticking to default for the other arguments
82+
)
83+
84+
# This Generator generates a sine signal to send to the Synchronizer Block
85+
# for interpolation
86+
gen_sig_1 = crappy.blocks.Generator(
87+
# Generating a sine signal of period 20s and amplitude 2
88+
({'type': 'Sine', 'freq': 0.05, 'amplitude': 2, 'condition': None},),
89+
cmd_label='sig_1', # The label carrying the generated signal
90+
freq=7, # Frequency is a prime number to ensure signals from several
91+
# Blocks will be desynchronized
92+
93+
# Sticking to default for the other arguments
94+
)
95+
96+
# This Generator generates a sine signal to send to the Synchronizer Block
97+
# for interpolation
98+
gen_sig_2 = crappy.blocks.Generator(
99+
# Generating a constant signal of value 0.5
100+
({'type': 'Constant', 'value': 0.5, 'condition': 'delay=20'},),
101+
cmd_label='sig_2', # The label carrying the generated signal
102+
spam=True, # Spamming enabled, otherwise only one value would get sent
103+
freq=5, # Frequency is a prime number to ensure signals from several
104+
# Blocks will be desynchronized
105+
106+
# Sticking to default for the other arguments
107+
)
108+
109+
sync = crappy.blocks.Synchronizer(reference_label='ref',
110+
time_label='t(s)',
111+
labels_to_sync=('sig_1', 'sig_2'),
112+
freq=20)
113+
114+
# This Grapher displays the interpolated data transmitted by the Synchronizer
115+
# Block. The 'sig_1' and 'sig_2' signals are now synchronized with the 'ref'
116+
# signal, and the 'ref' is preserved
117+
graph = crappy.blocks.Grapher(
118+
# The names of the labels to plot on the graph
119+
('t(s)', 'ref'), ('t(s)', 'sig_1'), ('t(s)', 'sig_2'),
120+
interp=False, # Displaying the data points only, no lines, to clearly
121+
# distinguish them
122+
length=40, # Only displaying the last 40 received values, so that the
123+
# data points remain always clearly visible
124+
freq=2, # Updating the graph twice every second
125+
126+
# Sticking to default for the other arguments
127+
)
128+
129+
# Linking the Block so that the information is correctly sent and received
130+
crappy.link(gen_ref, sync)
131+
crappy.link(gen_sig_1, sync)
132+
crappy.link(gen_sig_2, sync)
133+
crappy.link(sync, graph)
134+
135+
# Mandatory line for starting the test, this call is blocking
136+
crappy.start()

src/crappy/blocks/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
from .sink import Sink
2626
from .stop_block import StopBlock
2727
from .stop_button import StopButton
28+
from .synchronizer import Synchronizer
2829
from .ucontroller import UController
2930
from .video_extenso import VideoExtenso
3031

src/crappy/blocks/multiplexer.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,11 @@ class Multiplexer(Block):
1212
"""This Block takes data from upstream Blocks as input and interpolates it to
1313
output all the labels in a common time basis.
1414
15+
This Block is very similar to the :class:`~crappy.blocks.Synchronizer` Block,
16+
but the `Synchronizer` takes the timestamps of a reference label as a time
17+
base whereas this one performs the interpolation on a time base independent
18+
of the received labels.
19+
1520
It can take any number of inputs, provided that they all share a common time
1621
label. It is also possible to choose which labels are considered for
1722
multiplexing and which are dropped. The interpolation is performed using the

src/crappy/blocks/synchronizer.py

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
# coding: utf-8
2+
3+
import numpy as np
4+
from typing import Optional, Union, Iterable, Dict
5+
from collections import defaultdict
6+
import logging
7+
8+
from .meta_block import Block
9+
10+
11+
class Synchronizer(Block):
12+
"""This Block takes data from upstream Blocks as input and interpolates it to
13+
output all the labels on the same timestamps as a reference label.
14+
15+
This Block is very similar to the :class:`~crappy.blocks.Multiplexer` Block,
16+
but the `Multiplexer` interpolates data in a time base independent of the
17+
labels whereas this one takes one label as a reference.
18+
19+
It can take any number of inputs, provided that they all share a common time
20+
label. It is also possible to choose which labels are considered for
21+
interpolation and which are dropped. The interpolation is performed using the
22+
:obj:`numpy.interp` method.
23+
24+
This Block is useful for synchronizing data acquired from different sensors,
25+
in the context when one label should be treated as a reference. This is for
26+
example the case when synchronizing signals with the output of an image
27+
processing, to be able to compare all the signals in the time base of the
28+
image acquisition.
29+
30+
.. versionadded:: 2.0.5
31+
"""
32+
33+
def __init__(self,
34+
reference_label: str,
35+
time_label: str = 't(s)',
36+
labels_to_sync: Optional[Union[str, Iterable[str]]] = None,
37+
freq: Optional[float] = 50,
38+
display_freq: bool = False,
39+
debug: Optional[bool] = False) -> None:
40+
"""Sets the arguments and initializes the parent class.
41+
42+
Args:
43+
reference_label: The label whose timestamps will be taken as a time base
44+
for performing the interpolation.
45+
time_label: The label carrying the time information. Should be common to
46+
all the input Blocks.
47+
labels_to_sync: An iterable (like a :obj:`list` or a :obj:`tuple`)
48+
containing the labels to interpolate on the reference label's time
49+
base, except for the time label that is given separately in the
50+
``time_label`` argument. The Block also doesn't output anything until
51+
data has been received on all these labels. If left to :obj:`None`, all
52+
the received labels are considered. **It is recommended to always set
53+
this argument !** It is also possible to give this argument as a single
54+
:obj:`str` (i.e. not in an iterable).
55+
freq: The target looping frequency for the Block. If :obj:`None`, loops
56+
as fast as possible.
57+
display_freq: If :obj:`True`, displays the looping frequency of the
58+
Block.
59+
debug: If :obj:`True`, displays all the log messages including the
60+
:obj:`~logging.DEBUG` ones. If :obj:`False`, only displays the log
61+
messages with :obj:`~logging.INFO` level or higher. If :obj:`None`,
62+
disables logging for this Block.
63+
"""
64+
65+
super().__init__()
66+
self.freq = freq
67+
self.display_freq = display_freq
68+
self.debug = debug
69+
70+
# Initializing the attributes
71+
self._ref_label = reference_label
72+
self._time_label = time_label
73+
self._data: Dict[str, np.ndarray] = defaultdict(self._default_array)
74+
75+
# Forcing the labels_to_sync into a list
76+
if labels_to_sync is not None and isinstance(labels_to_sync, str):
77+
self._to_sync = [labels_to_sync]
78+
elif labels_to_sync is not None:
79+
self._to_sync = list(labels_to_sync)
80+
else:
81+
self._to_sync = None
82+
83+
def loop(self) -> None:
84+
"""Receives data, interpolates it, and sends it to the downstream
85+
Blocks."""
86+
87+
# Receiving all the upcoming data
88+
data = self.recv_all_data_raw()
89+
90+
# Iterating over all the links
91+
for link_data in data:
92+
# Only data associated with a time label can be synchronized
93+
if self._time_label not in link_data:
94+
continue
95+
# Extracting the time information from the data
96+
timestamps = link_data.pop(self._time_label)
97+
98+
# Adding data from each label in the buffer
99+
for label, values in link_data.items():
100+
# Only the labels specified in out_labels is considered
101+
if (self._to_sync is not None and label not in self._to_sync
102+
and label != self._ref_label):
103+
continue
104+
105+
# Adding the received values to the buffered ones
106+
self._data[label] = np.concatenate((self._data[label],
107+
np.array((timestamps, values))),
108+
axis=1)
109+
# Sorting the buffered data, if a same label comes from multiple Links
110+
self._data[label] = self._data[label][
111+
:, self._data[label][0].argsort()]
112+
113+
# Aborting if there's no data to process
114+
if not self._data:
115+
self.log(logging.DEBUG, "No data in the buffer to process")
116+
return
117+
118+
# Aborting if there's no data for the reference label
119+
if self._ref_label not in self._data:
120+
self.log(logging.DEBUG, "No value for the reference label found in "
121+
"the buffer")
122+
return
123+
124+
# Making sure there's data for all the requested labels
125+
if (self._to_sync is not None and
126+
any(label not in self._data for label in self._to_sync)):
127+
self.log(logging.DEBUG, "Not all the requested labels received yet")
128+
return
129+
130+
# There should also be at least two values for each label
131+
if any(len(self._data[label][0]) < 2 for label in self._data):
132+
self.log(logging.DEBUG, "Not at least 2 values for each label in buffer")
133+
return
134+
135+
# Getting the minimum time for the interpolation (maximin over all labels)
136+
min_t = max(data[0, 0] for data in self._data.values())
137+
138+
# Getting the maximum time for the interpolation (minimax over all labels)
139+
max_t = min(data[0, -1] for data in self._data.values())
140+
141+
# Checking if there's a valid time range for interpolation
142+
if max_t < min_t:
143+
self.log(logging.DEBUG, "Ranges not matching for interpolation")
144+
return
145+
146+
# The array containing the timestamps for interpolating
147+
interp_times = self._data[self._ref_label][0,
148+
(self._data[self._ref_label][0] >= min_t) &
149+
(self._data[self._ref_label][0] <= max_t)]
150+
151+
# Checking if there are values for the target label in the valid time range
152+
if not np.any(interp_times):
153+
self.log(logging.DEBUG,
154+
"No value of the target label found between the minimum and "
155+
"maximum possible interpolation times")
156+
return
157+
158+
to_send = dict()
159+
160+
# Building the dict of values to send
161+
for label, values in self._data.items():
162+
to_send[label] = list(np.interp(interp_times, values[0], values[1]))
163+
# Keeping the last data point before max_t to pass this information on
164+
last = values[:, values[0] <= max_t][:, -1]
165+
# Removing the used values from the buffer, except the last data point
166+
self._data[label] = np.column_stack((last, values[:, values[0] > max_t]))
167+
168+
if to_send:
169+
# Adding the time values to the dict of values to send
170+
to_send[self._time_label] = list(interp_times)
171+
172+
# Sending the values
173+
for i, _ in enumerate(interp_times):
174+
self.send({label: values[i] for label, values in to_send.items()})
175+
176+
@staticmethod
177+
def _default_array() -> np.ndarray:
178+
"""Helper function for the default dict."""
179+
180+
return np.array(([], []))

0 commit comments

Comments
 (0)