Device Synchronisation - Cable, Light and Sound Approaches
Difficulty Level:
Tags pre-process☁light☁sound☁sync☁correlation

When acquiring data with multiple devices, it may be difficult to start the acquisition at exactly the same instant. This means that events that start at exactly the same time, may appear to be delayed for signals of different devices.

Data processing and signal analysis may depend on the acquisition instants and the synchrony of different devices in order to understand the causes of different processes. For example, when dealing with ECG data, recorded during the execution of physical exercise with variable intensity, it may be important to know the instants in which a difference of physical intensity take place, to match with the correct period of heartbeating.

In this Jupyter Notebook , two methods to synchronise signals will be explained using different triggers and sensors.

Note : For a high level explanation of how to synchronise signals, we recommend you to see the notebooks:


1 - Import the required packages

In order to facilitate our calculus, we will be using methods from the </span> biosignalsnotebooks and numpy Python packages, that have several mathematical and auxiliary functions to ease our work.

In [1]:
# biosignalsnotebooks own package for loading and plotting the acquired data
import biosignalsnotebooks as bsnb

# Scientific packages
import numpy as np

Generic Synchronisation Example

There are various methods that can be used in order to synchronise signals. We will show two of the most used techniques, one based on the definition of a threshold and one based on the analysis of the cross-correlation between two signals.
Our methods can be applied to multiple sources of one-dimensional signals, such as ECG, EEG or accelerometer, thus, we will show how to use them with synthetic signals and then we will apply them to real signals acquired with two biosignalsplux devices.

2 - Generate synthetic signals

We will start by generating signals consisting of sine waves with the same period (frequency) and amplitude, but different phase.

In [2]:
# Generate a time axis (0 to 100 s with 10 Hz sampling rate)
t = np.arange(0,100,0.1)

# Generate two similar dephased signals
period = 2*np.pi/10
phase = np.pi/2
signal_1 = np.sin(period * t)
signal_2 = np.sin(period * t + phase)
In [3]:
bsnb.plot([t, t], [signal_1, signal_2])

The sampling frequency was defined to 10 Hz and the period was defined to 10 time units, thus each period of each signal is composed of 100 data points. The delay corresponds to $\frac{phase}{period}$ of the period of the signals, hence, is expected to be 2.5 time units, thus, 25 data points.

3 - Synchronise signals using a threshold

This method, may be viewed as a naive perspective due to the intuition that gives origin to it. It is assumed that both signals have identical behaviour and, then, equal values should be temporally aligned. Thus, by defining a threshold, we say that the moment when the two signals pass it, corresponds to a moment that should be aligned.

First, we normalise the signals in order to have them in the same order of amplitude. In this case, we used the z-score normalisation, which results in signal with zero mean and unit variance.

In [4]:
# Normalise the first signal
m_signal_1 = signal_1 - np.mean(signal_1); w_signal_1 = m_signal_1 / np.std(m_signal_1)

# Normalise the second signal
m_signal_2 = signal_2 - np.mean(signal_2); w_signal_2 = m_signal_2 / np.std(m_signal_2)

For the threshold, we defined an absolute threshold to be applied to the normalised signals, but it could be a relative value, such as 80% of the maximum value of one of the normalised signals.

In [5]:
# Define the absolute value:
threshold = 0.8

# Defining a relative threshold, 80% of the maximum value of the first signal normalised:
# relative_threshold = 0.8 * np.max(w_signal_1)

After defining the threshold, we should align the signals by the first point in which the signals pass above that threshold. The opposite could also be done, align the signals by the point in which the signals pass from above to below the threshold.

In [6]:
# np.where function allows to get the values where each signal are higher than the threshold.
# Then, we just need to get the first index in which the signals pass the threshold, thus the indexing.
align_signal_1 = np.where(w_signal_1 > threshold)[0][0]
align_signal_2 = np.where(w_signal_2 > threshold)[0][0]

The values align_signal_1 and align_signal_2 are the respective first points where signal_1 and signal_2 surpass the threshold. Having the indexes of alignment, we can align the signals.

In [7]:
signal_1_aligned = signal_1[align_signal_1:]
signal_2_aligned = signal_2[align_signal_2:]
In [8]:
bsnb.plot([np.arange(len(signal_1_aligned)), np.arange(len(signal_2_aligned))], [signal_1_aligned, signal_2_aligned])

The signals did not end up synchronised. This is due to the difficulty of choosing a correct value for the threshold. If the threshold had been defined to 1.4, the results would improve as can be seen in the next figure. Nevertheless, this is an unexpensive computational method that may work in real time and when the threshold is well defined can present good results.

In [9]:
# Definition of the threshold.
threshold = 1.4

align_signal_1 = np.where(w_signal_1 > threshold)[0][0]
align_signal_2 = np.where(w_signal_2 > threshold)[0][0]

signal_1_aligned = signal_1[align_signal_1:]
signal_2_aligned = signal_2[align_signal_2:]

bsnb.plot([np.arange(len(signal_1_aligned)), np.arange(len(signal_2_aligned))], [signal_1_aligned, signal_2_aligned])

4 - Synchronise signals using the full cross-correlation between two signals

This is a more robust method that can not be applied in real time, but does not depend on the selection of parameters. It starts by calculating the full cross-correlation between two signals.
Cross-correlation is a metric that allows to assess the similarity between two signals ( f and g ) by the displacement of one of the signals along the other. Mathematically it can be represented by: \begin{equation} (f \star g)(n) = \sum_{m = -\infty}^{+ \infty} f(m) g(m+n) \end{equation} being $m$ the number of the sample under analysis, while $n$ defines the displacement/lag of g in relation to $f(m)$.

Once the signals are not infinite, computational systems compute the cross-correlation in instants where the signal is not defined by zero-padding the needed time instants, resulting in a correlation signal with length of the sum of both signals. The resulting correlation signal may be influenced by the zero-padding, but it is still possible to use this metric to calculate the dephasing of signals and it may be used to synchronise them.

Once again, we start by normalising the signals with the z-score normalisation technique.

In [10]:
# Normalise the signals using the z-score normalisation.
m_signal_1 = signal_1 - np.mean(signal_1); w_signal_1 = m_signal_1 / np.std(m_signal_1)
m_signal_2 = signal_2 - np.mean(signal_2); w_signal_2 = m_signal_2 / np.std(m_signal_2)

Fortunately, numpy has an implementation of the correlation function, so we will not need to program it ourselves. The application of this function only takes one step and allows to calculate the full cross-correlation between two signals, returning the resulting correlation signal.

In [11]:
# Calculate the full cross-correlation between the two signals.
correlation = np.correlate(w_signal_1, w_signal_2, 'full')
In [12]:
bsnb.plot(np.arange(len(correlation)), correlation)

As can be seen through the previous figure, the correlation signal increases as it reaches the centre and then decreases again. This is caused by the referred zero-padding, which does not contribute to the correlation of the two signals, resulting in "tails" with decreasing values starting from the points of full overlap (in fact, the size of those tails is equal to the length of the shorter signal).

In this case, once the signals are similar in length, the centre of the signal corresponds to the full overlap of the two signals, thus, if they were phased, the maximum value of the correlation signal, which corresponds to the instant of synchronisation of the signals, should correspond to the centre of the x axis.

In order to calculate the difference of phases, we can calculate the distance between the centre of the correlation signal and the instant in which occurs the maximum value.

In [13]:
# Finding the portion of the signal that does not belong to the tails. 
# This is done by subtracting the length of the shorter signal to length of the correlation signal. 
# In this case the signals have equal lengths, so it is enough to divide the correlation signal by two.
center = len(correlation)//2

# Finding the position of the maximum value of the correlation signal
max_position = correlation.argmax()

# Calculate the phase between the signals. The np.abs calculates the absolute value, assuring that the value is not negative.
phase = np.abs(center - max_position)
In [14]:
bsnb.plot([np.arange(len(signal_1[25:])), np.arange(len(signal_2))], [signal_1[25:], signal_2], legend=['signal_1', 'signal_2'])
print("Phase difference in data points: %d" % (phase))
Phase difference in data points: 25

The result shows that the usage of the cross-correlation allowed to calculate the correct dephasing between the two signals without the need to define any parameter.

4.1 - Calculate the delay between two signals that generate specific correlation signals

There are cases where the correlation signal presents a plateau at the maximum value, especially if the signals are not standardised. In these cases, the aforementioned method may result in a a phase value of 0 even when there is a delay.
In this topic it will be shown one example that can generate such results and how to resolve them. This method is more generic than the previous one and can be used to all cases.

In [15]:
# Generate step signals that start with zeros and pass to ones at different moments.
x = np.concatenate([np.zeros(500), np.ones(500)]) 
y = np.concatenate([np.zeros(400), np.ones(600)])
In [16]:
time_axis = np.arange(0, 1000)
bsnb.plot([time_axis, time_axis], [x, y], legend=['x signal', 'y signal'])

As can be seen by the previous figure, the dephasing of the two signals is of 100 data points.

Next, we calculate the full cross-correlation between the two signals using the numpy Python package.

In [17]:
# Calculate the cross-correlation between the two signals
corr = np.correlate(x, y, 'full')
In [18]:
bsnb.plot(np.arange(len(corr)), corr)

The next cells correspond to the presentation of the more generic method to calculate the dephasing between two signals using the full cross-correlation. Similarly to the previous method, we will try to find the difference between the position of the maximum value and the positions corresponding to the full overlap between the two signals, that in this case, corresponds to the central data point of the correlation signal.

In [19]:
# Finding the centre of the signal
centre = len(corr)//2

# Finding the position of the maximum value of the signal
max_position = corr.argmax()

# Calculating the difference between the centre and the position of the maximum value
phase_straight = np.abs(centre - max_position)

The difference occurs now. We have found the position of the maximum value in the correlation signal and also the difference between that position and the full overlap of the two signals. Once there is a plateau that extends between the centre of the signal and the true dephasing between the signals, the result will be 0, because it is the first time that the maximum value appears, but it does not correspond to the true, as can be seen by the figure where we show the two signals.

Therefore, we should find the maximum value of the reversed correlation signal, that, in this case, would give us the real dephasing between the two signals.
This step will give us the first point where the reversed correlation signal reaches the maximum value, that corresponds to the last point where the straight correlation signal reaches that value.

In [20]:
# Calculate the reversed correlation signal.
reversed_correlation_signal = corr[::-1]

# Finding the position of the maximum value of the reversed signal.
max_position_reversed = reversed_correlation_signal.argmax()

# Calculating the difference between the centre and the position of the maximum value in the reversed correlation signal
phase_reversed = np.abs(centre - max_position_reversed)

In order to calculate the true dephasing between the two signals, we should find the maximum dephasing between the calculated values - of the straight and the reversed correlation signals. Thus, we guarantee that we find the true dephasing between two signals independently of wich one is the delayed signal.
Note : If the computation of the full cross-correlation was made in reverse, np.correlate(y, x, "full") , the correlation signal would be mirrored and the previous method would give the true dephasing between the two signals, but we could still use the generic method due to this step.

In [21]:
# Calculate the dephasing between the signals. Maximum value of both 
# results guarantees that we find the true dephasing of the signals 
phase = np.max([phase_straight, phase_reversed])
In [22]:
print("Phase difference by the previous method: %d" % (phase_straight))
print("Phase difference by the new method: %d" % (phase))
Phase difference by the previous method: 0
Phase difference by the new method: 100

5 - Synchronising the signals

Finally, to synchronise both signals, we may crop one of the signals in order to align them by the most representative feature. In this case, the most representative feature is the step of both signals.

In [23]:
x = x[phase:]
In [24]:
bsnb.plot([np.arange(len(x)), np.arange(len(y))], [x, y], legend=['x signal', 'y signal'])

Demonstration using an acoustic signal

This synchronisation method may be applied to generic cases. In this specific application, it was acquired an acoustic signal by two different biosignalsplux in the same room. The acquisitions did not start at same time, and so the signals are dephased by some seconds.

In order to validate the synchronisation approach, it was produced one distinctive event (clapping) that can help us to synchronise our signals. The idea is that, even if we did not know the difference in time between the start of the acquisition, we are certain that the clap was produced at the same instant for both devices.

1 - Loading data from file

In [25]:
# Load the first file
relative_path_sound = r"..\..\signal_samples\sync_acoustic_1.txt"
file_sound_1 = open(relative_path_sound)
# Store only the signal column
data_sound_1 = np.loadtxt(file_sound_1)[:,2]

# Load the second file
relative_path_sound = r"..\..\signal_samples\sync_acoustic_2.txt"
file_sound_2 = open(relative_path_sound)
# Store only the signal column
data_sound_2 = np.loadtxt(file_sound_2)[:,2]
In [26]:
bsnb.plot([np.arange(len(data_sound_1)), np.arange(len(data_sound_2))], [data_sound_1, data_sound_2], legend=['data_sound_1', 'data_sound_2'])