Tutorial 1 : The Signal class

The measpy.signal.Signal class describes a sampled temporal signal. It is at the core of the measpy.Measurement class, which helps in performing data acquisition with various daq cards.

An object of the class Signal is described by the following properties:

  • A sampling frequency fs

  • A physical unit unit (optionnal)

  • A calibration cal in unit per Volt (if the signal comes from an DAQ acquisition) (optionnal)

  • A multiplicative constant dbfs which express the voltage amplitude for which the acquired data equals 1 (one). This is a classical quantity when acquiring signals with audio cards, but can be ignored and kept at 1.0 with most data acquisition cards. (optionnal)

  • A numpy array _rawvalues, which corresponds to the raw data given by the digital acquisition process.

  • A description desc (a string that describes the signal)

  • A time shift t0 (optionnal)

  • Any other property can be added to a Signal object. It is saved and restored by the file management methods of this class.

Let’s first import the measpy module, as well as numpy:

#This is here in case we want to use the local measpy directory
import sys
sys.path.insert(0, "..")

import measpy as mp
import numpy as np

1. Basics

Let us create a signal S with a sampling frequency of 44100 Hz.

If unit, calibration and dbfs are not specified they are defaulted to dimensionless, 1.0 and 1.0 respectively. And if no content is given the samples doesn’t exist and _rawvalues equals None.

S = mp.Signal(fs=44100)
print(S)
print(S._rawvalues)
measpy.Signal(desc='A signal',
fs=44100,
_rawvalues=[],
)
[]

As such, the above signal isn’t very useful. Let us give to it some content, for instance a sinusoid at the frequency 100Hz of duration 1 second. raw property is a shortcut (@setter,@getter) for the property _rawvalues.

S.raw = np.sin(2*np.pi*20*np.arange(0,1,1/S.fs))
S.desc = 'A sinusoidal signal at 20Hz'
print(S.raw)
print(S._rawvalues)
[ 0.          0.00284951  0.005699   ... -0.00854845 -0.005699
 -0.00284951]
[ 0.          0.00284951  0.005699   ... -0.00854845 -0.005699
 -0.00284951]

Note that using S._rawvalues instead of S.raw would lead to the same result. More precisely the actual array is S._rawvalues, and S.raw is a shortcut (a @setter/@getter method).

Now that it contains data, the signal can be plotted.

S.plot()
<Axes: xlabel='Time (s)'>

png

All what we have done before could be performed in one step at the initialization phase, as below.

samplingfreq = 44100
S = mp.Signal(fs=samplingfreq,desc='20Hz sinusoidal signal',raw=np.sin(2*np.pi*20*np.arange(0,1,1/samplingfreq)))
S.plot()
<Axes: xlabel='Time (s)'>

png

Even better, there is the sine method of the class Signal:

S = mp.Signal.sine(freq=20,dur=1,fs=44100,amp=1)

There is a lot of additionnal properties for the signal. For instance, the duration and the time vector. These properties are not stored in memory. When asked for, they are calculated based on the number of samples in the signal, as in the example below:

print(S.time)
print(S.dur)
[0.00000000e+00 2.26757370e-05 4.53514739e-05 ... 9.99931973e-01
 9.99954649e-01 9.99977324e-01]
1.0

2. Units

Let us now give a physical unit to this signal. Consider for instance that it is a velocity, in meter per second. In order to create such signal we have to additionnally specify the unit optionnal argument:

velsig = mp.Signal.sine(fs=samplingfreq,freq=20.0,desc='20Hz sinusoidal velocity',dur=1.0,unit='m/s')

Consider a second signal, for instance a force in Newtons (N), in phase with the velocity. It is created in the same way:

forcesig = mp.Signal.sine(fs=samplingfreq,freq=20.0,desc='20Hz sinusoidal force',dur=1.0,unit='N')

These two signal can be multiplied together, giving an instantaneous power!

power = forcesig * velsig
power.plot()
<Axes: xlabel='Time (s)'>

png

Note that by default, the units are simply multiplied together. It is however possible to ask for a standard unit of the same dimension for this signal. The below command will convert the signal to the standard unit for power (which is Watt)

power = power.unit_to_std()
print(power)
measpy.Signal(fs=44100,
unit=W,
desc='20Hz sinusoidal force
 * 20Hz sinusoidal velocity
 -->Unit to W',
freq=20.0,
_rawvalues=[0.00000000e+00 8.11972599e-06 3.24786402e-05 ... 7.30759516e-05
 3.24786402e-05 8.11972599e-06],
)

The similar method makes a copy of the Signal object and modifies its properties specified in its arguments. Using this method, we can do the same as before and change the description at the same time.

power = (velsig*forcesig).unit_to_std().similar(desc='The power given to the system')
print(power)
measpy.Signal(fs=44100,
unit=W,
desc='The power given to the system',
freq=20.0,
_rawvalues=[0.00000000e+00 8.11972599e-06 3.24786402e-05 ... 7.30759516e-05
 3.24786402e-05 8.11972599e-06],
)

Conversely to multiplication, in order to perform an addition, the signals have to be of compatible physical units. We cannot add force and velocity! The below command should raise an Exception.

forcesig+velsig
---------------------------------------------------------------------------

Exception                                 Traceback (most recent call last)

Cell In[13], line 1
----> 1 forcesig+velsig


File ~/Documents/python/measpy/examples/../measpy/signal.py:1176, in Signal.__add__(self, other)
   1170 """Add something to the signal
   1171 
   1172 :param other: Something to add to
   1173 :type other: Signal, float, int, scalar quantity
   1174 """
   1175 if type(other) == Signal:
-> 1176     return self._add(other)
   1178 if (type(other) == float) or (type(other) == int) or (type(other) == complex) or isinstance(other, numbers.Number):
   1179     # print('Add with a number without unit, it is considered to be of same unit')
   1180     return self._add(
   1181         self.similar(
   1182             values=np.ones_like(self.values)*other,
   1183             desc=str(other)
   1184         )
   1185     )


File ~/Documents/python/measpy/examples/../measpy/signal.py:1155, in Signal._add(self, other)
   1146 """Add two signals
   1147 
   1148 :param other: Other signal to add
   (...)
   1151 :rtype: Signal
   1152 """
   1154 if not self.unit.same_dimensions_as(other.unit):
-> 1155     raise Exception('Incompatible units in addition of sginals')
   1156 if self.fs != other.fs:
   1157     raise Exception(
   1158         'Incompatible sampling frequencies in addition of signals')


Exception: Incompatible units in addition of sginals

Added signals can have different dimensions if they are compatible. Let us create a signal in meter, a signal in decameter, add them, and finally plot the resulting signal.

distance1 = mp.Signal(fs=samplingfreq,desc='A length',raw=np.sin(2*np.pi*100*np.arange(0,1,1/samplingfreq)),unit='m')
distance2 = mp.Signal(fs=samplingfreq,desc='Another length',raw=np.sin(2*np.pi*120*np.arange(0,1,1/samplingfreq)),unit='dam')
distancetot = distance1+distance2
distancetot.plot()
<Axes: xlabel='Time (s)'>

png

When performing addition of signals, the final unit is that of the first operand. That’s why we get meters in the above calculation. If we switch the signals, we get the same thing, but in decameters…

distancetot = distance2+distance1
distancetot.plot()
<Axes: xlabel='Time (s)'>

png

We might want to convert this to millimeters… There is a method for that.

distancetot.unit_to('mm').plot()
<Axes: xlabel='Time (s)'>

png

Any operation on signals necessitates that the operands are signals of same length and frequency. For instance, consider a for signal of different sampling frequency, and try to add it to the previous force signal. This should give us an error.

samplingfreq2=48000
forcesig2 = mp.Signal.sine(fs=samplingfreq2,freq=20.0,dur=1.0,desc='100Hz sinusoidal force at fs=48000Hz',unit='N')
forcetot = forcesig+forcesig2
---------------------------------------------------------------------------

Exception                                 Traceback (most recent call last)

Cell In[17], line 3
      1 samplingfreq2=48000
      2 forcesig2 = mp.Signal.sine(fs=samplingfreq2,freq=20.0,dur=1.0,desc='100Hz sinusoidal force at fs=48000Hz',unit='N')
----> 3 forcetot = forcesig+forcesig2


File ~/Documents/python/measpy/examples/../measpy/signal.py:1176, in Signal.__add__(self, other)
   1170 """Add something to the signal
   1171 
   1172 :param other: Something to add to
   1173 :type other: Signal, float, int, scalar quantity
   1174 """
   1175 if type(other) == Signal:
-> 1176     return self._add(other)
   1178 if (type(other) == float) or (type(other) == int) or (type(other) == complex) or isinstance(other, numbers.Number):
   1179     # print('Add with a number without unit, it is considered to be of same unit')
   1180     return self._add(
   1181         self.similar(
   1182             values=np.ones_like(self.values)*other,
   1183             desc=str(other)
   1184         )
   1185     )


File ~/Documents/python/measpy/examples/../measpy/signal.py:1157, in Signal._add(self, other)
   1155     raise Exception('Incompatible units in addition of sginals')
   1156 if self.fs != other.fs:
-> 1157     raise Exception(
   1158         'Incompatible sampling frequencies in addition of signals')
   1159 if self.length != other.length:
   1160     raise Exception('Incompatible signal lengths')


Exception: Incompatible sampling frequencies in addition of signals

But we can try to resample one signal to match the sampling frequency of the orther. If the two signals are of same length after one is resampled to match the other, this will work…

forcetot = forcesig+forcesig2.resample(forcesig.fs)
forcetot.plot()
<Axes: xlabel='Time (s)'>

png

3. Calibrations

Up to now, we didn’t care about calibration. In fact, if we worked only with such synthesized signal, there wouldn’t be any reason to.

Most of the time, these signals come from data acquisition process, where an accelerometer, a force/torque sensor, a microphone or an optical velocity measurement device is involved.

Conditionners generally produce a voltage signal that is proportionnal to the measured quantity. The calibration cal is hence a float number which in expressed in Volts/units.

The data acquisition device the captures this voltage signal at a given sampling frequency. If the acquired samples directly express the voltage input, dbfs=1. With some devices (for instance line inputs of sound cards), the acquired sample values are proportionnal, but not equal. The coefficient of proportionnality is dbfs.

The true signal expressed in unit is hence given by multiplying the _rawvalues property by dbfs (to convert it in volts) and dividing the resulting array by cal.

There are methods for that.

Consider the signal below. A sinusoidal velocity. The calibration was 2V/(m/s) and the input measures 1.0 when there is 5 incoming Volts.

velsig = mp.Signal(fs=samplingfreq,
    desc='100Hz sinusoidal velocity',
    raw=np.sin(2*np.pi*100*np.arange(0,1,1/samplingfreq)),
    unit='m/s',
    cal=2.0,
    dbfs=5.0)

The raw values given by the sound card are accessed by using the _rawvalues property or raw, which is a shortcut method, and is preferrable.

velsig.raw
array([ 0.        ,  0.0142471 ,  0.02849132, ..., -0.04272974,
       -0.02849132, -0.0142471 ])

We might want for some reason the actual voltage that was going into the daq card.

velsig.volts
array([ 0.        ,  0.07123552,  0.14245658, ..., -0.21364872,
       -0.14245658, -0.07123552])

The measured values in m/s are accessed using the values property… And this is what is plotted when we use the velsig.plot() method.

print(velsig.values)
velsig.plot()
[ 0.          0.03561776  0.07122829 ... -0.10682436 -0.07122829
 -0.03561776]





<Axes: xlabel='Time (s)'>

png

We have seen that velsig.volts returns a numpy array with the actual acquired voltages. In cas we need that in the form of a Signal, there is the as_volts() method:

velsig.as_volts()
measpy.Signal(fs=44100,
desc='100Hz sinusoidal velocity
 -->Voltage',
unit=V,
_rawvalues=[ 0.          0.07123552  0.14245658 ... -0.21364872 -0.14245658
 -0.07123552],
)

There is also the as_raw() method of course.

4. File I/O

The preferred format for saving a Signal object is a combination of a CSV file for the properties and a WAV file for the data. Simply use the method to_csvwav, which takes a string as argument.

velsig.to_csvwav('velocity')

The above command creates the files velocity.csv and velocity.wav.

To load it again, use the @classmethod from_csvwav, as below:

vel = mp.Signal.from_csvwav('velocity')
vel.plot()
---------------------------------------------------------------------------

AttributeError                            Traceback (most recent call last)

Cell In[25], line 2
      1 vel = mp.Signal.from_csvwav('velocity')
----> 2 vel.plot()


File ~/Documents/python/measpy/examples/../measpy/signal.py:1629, in Signal.plot(self, ax, **kwargs)
   1618 def plot(self, ax=None, **kwargs):
   1619     """ Basic plotting of the signal
   1620         Optionnal arguments:
   1621 
   (...)
   1626         - ax : an axes object
   1627     """
-> 1629     kwargs.setdefault("label", self.desc+' ['+str(self.unit.units)+']')
   1631     if ax == None:
   1632         _, ax = plt.subplots(1)


AttributeError: 'str' object has no attribute 'units'

There is also a from_wav @classmethod which allows to import a WAV file. As only the data and the sampling frequency are stored in a WAV file, the other properties have to be specified as optionnal named parameters if necessary (unit,calibrations, etc.)

vel = mp.Signal.from_wav('velocity.wav',unit='m/s',desc='This is a velocity')