Signal Chain Analysis in Python. A Case Study for Hardware Engineers | by Gael Close | Towards Data Science

Signal chains are common in hardware electronics. Typically a physical signal (temperature, position, pressure ...) is transformed through the chain via a series of operations. Given the practical hardware limitations, the transformation introduces some errors. Predicting the overall performance in the presence of errors requires a mathematical analysis for which Python is well suited. This tutorial article provides a complete reproducible analysis for an illustrative case study. The case study is a sensor readout chain spanning multiple domains (mechanical, magnetic, analog hardware and digital signal processing). The article demonstrates the capability of Python as a first-class engineering design tool. It is a continuation of a previous article [1] introducing a coding pattern. The analysis here uses this coding pattern, and focuses on the engineering aspects.
Real-world signals such as temperature, pressure, position, are typically processed by hardware signal chain. A typical chain consists of analog electronics, a conversion to digital data, and further digital processing operations. The practical hardware non-idealities, such as noise and the limited number of bits, degrade the signal quality. To quantify the performance, a mathematical model of the chain faithfully capturing the errors is then needed. Python, with its extensive ecosystem of math and engineering libraries, is well suited for coding such model. In addition, Jupyter notebooks mixing calculation, plots and the narrative story provide an ideal format to share engineering analysis reports with colleagues, stakeholders, or even the world. The analysis can be reproduced with minimal infrastructure: just a browser. Also the open-source nature of this ecosystem is not just good for the wallet, but promotes learning and collaboration.
Despite these advantages, engineers without software background often find it hard to get started with modeling and analysis in Python. Consequently, these engineers tends to revert back to spreadsheet for system modeling. Excel is still the most used modeling language [2]— severely restricting the modeling capabilities.
In this article, we demonstrate the capability of Python to support a complete signal chain analysis. We take a tutorial approach covering the analysis end to end. We start from the chain block diagram, discussing the modeling code for each block. We explain a generic analysis flow, and summarize our results in a publication-quality interactive dashboard to be shared with stakeholder.
As a concrete use case, we apply the method to a smart sensor readout chain. The example is inspired from the author professional experience [3]. However the design and parameters are fictitious. The modeling and analysis techniques are general though. The merit of this illustrative chain is that it is general enough to cover a wide class of signal chains. It includes non-linear operations and non-trivial errors like found in practical hardware realizations.
The complete code is made available in the form of a companion Jupyter notebook. This notebook is fully executable there. Readers are encouraged to play around with the notebook, reproducing the plots and dashboard discussed below.
As a practical example, we consider the signal chain of a smart magnetic position sensor. The sensor purpose is to measure the angular position of a rotating magnet. Such sensors are ubiquitous in modern cars and countless other industrial and robotic applications.
The chain is depicted below and covers multiple engineering domains. From left to right, the magnet generates a magnetic field. The mechanical rotation of the magnet modulates the magnetic signal sensed by the sensor. The sensor transduces (=transforms in voltage) the magnetic field components along orthogonal sensing axis. The two voltages are amplified and converted to digital bits by standard analog electronics. The angle is calculated via an arctangent operation (realized by the CORDIC algorithm) on the two digital components.
If the chain was ideal, the analysis could be performed analytically, or simulated in a simple spreadsheet. In practice, the chain suffers from non-idealities. They corrupt the signal in non-trivial ways, making the analysis unmanageable in a spreadsheet apart from the simplest cases.
With Python on the other hand, we can decompose the chain into modular functional blocks. In addition, we can rely on a rich ecosystem of proven open-source high-level libraries for the complicated math. Thereby keeping the focus on the engineering aspects.
Consider the following error sources (highlighted and numbered in red in the diagram above).
(1) Mechanical misalignment. The sensor is not aligned perfectly with the magnet rotation axis. Imagine that there is some eccentricity and a tilt angle. This makes the math challenging: the simple textbook formulas for the field on the axis of symmetry of a magnet are not valid anymore. Instead the model needs to account for the vector field in 3-D as given by the equations in [4]. In addition, expressing this field in the frame of the sensor required the use of rotation. The first challenge is elegantly solved by the open-source library Magpy [5] providing the field for various magnet geometries. Concerning the rotation, basic rotation operations in a variety of format (Euler angle, angle-axis, matrix...) are available in the standard scipy.spatial.transform package.
(2) Electronic noise. The sensed signals are in the millivolt range, and hence corrupted by electronic noise. Let’s assume that the noise source can be modeled as Flicker noise, also known as “pink noise”. This type of noise is frequent in electronics. Unlike with purely random “white-noise”, neighboring samples are somewhat correlated like in a random drift or diffusion process. Again, the algorithm to generate a noisy sequence with this property is evolved. There are multiple off-the-shelf open-source Python libraries generating such a noisy waveform with one line of code. One example is [6].
(3) Quantization errors. Digital operations suffer from the limited precision of the binary arithmetic. For example, the analog-to-digital converter (ADC) has a limited number of bits. This is easily modeled as a rounding operation. Modeling the limited precision of trigonometric function implemented in hardware is more evolved. Consider the CORDIC algorithm: it decomposes the arctangent operation into a series of rotation with decreasing angle. The resulting angle error depends again upon the number of bits but also on the number of iteration. Again there exists off-the-shelf open-source Python libraries like [7] providing high-level model of this operation.
The cumulative effect of the above three errors is that the measured output angle θo is only approximately equal to the input angle θi of the magnet. The angle error consists of random fluctuation and non-linear distortion. A spectral analysis based on the Fast Fourier Transform (FFT) can distinguish between these contributions providing engineering insight. Again the FFT function is readily available in the standard scipy package.
Overall the Python ecosystem provides us with high-level libraries for modeling the chain, its error, and the mathematical signal analysis. Following the coding pattern we introduced in our previous article [1], the model will consists of a series of modular block, thereby closely mimicking the structure of the block diagram. For each block, we will define a function y=f(x) capturing the transformation from inputs to outputs.
The block function will typically be small in terms of lines of code despite capturing complicated math thanks to the high-level libraries. The code will remain lean and close in spirit to the engineering diagram.
Note that the coding pattern is restricted to chains without feedback loop and dynamics. An extension is needed to cover these case. This will be covered in a follow-up article.
Given that the full code is available in the companion Jupyter notebook, we don’t dive into all details here. Instead we focus on the engineering aspects and the overall approach. We start by listing all engineering parameters characterizing the chain and its error sources.
Consider first the magnet. It is characterized by its dimension (a 3-element list), its magnetization vector (another 3-element list) and its position (another 3-element list). For the sensor, we have the position, the tilt angle, its sensitivity and the noise RMS (root-mean-square) amplitude. For the ADC, we have the rounding step and the number of bits. For the CORDIC algorithm, we have the number of iteration.
Like recommended in our previous article [1], we group all fixed parameters in a data dictionary for convenience. Instead of a plain dictionary, we use a Box data structure: this way the parameters can be invoked concisely with dd.block.parameter.
Grouping the parameter in a single data structure provides a single point of truth for the set of parameters for given simulation run. This simplifies the management of multiple simulation runs during parametric studies.
A couple of extra derived parameters are typically also of interest to ease the calculation. Here the airgap between the sensor and magnet edge, and the sensor rotation matrix are used by intermediate calculation. As both are derived directly from the primary set of parameters, we integrate them inside the data dictionary for convenience. For modularity, we wrap the data dictionary definition and the calculation of derived parameters inside support functions. We obtain the following code.
#----------------------# Parameters#----------------------def load_dd():  return Box({    'magnet'  : {      'dim'   : [10,10,10],  # magnet dimension Lx, Ly, Lz [mm]      'mag'   : [-1,0,0],    # magnetization vector Br [Tesla]        'pos'   : [0,0,15] },  # magnet center [mm]     'sensor'  : {      'pos'   : [0,1,0],     # sensor center [mm]      'tilt'  : 3,           # sensor tilt [°]      'S'     : 1.0,         # sensor sensitivity [Volt/Telsa]      'noise' : 0.2 },       # sensor input-referred noise [Tesla]    'adc': {       'lsb'  : 0.1,         # ADC LSB size rounding step       'n'    : 12 },        # ADC number of bits     'cordic'  : {      'n'     : 12, }        # CORDIC number of iterations   })def calc_derived(dd):  dd.sensor.airgap =dd.magnet.pos[2] - dd.magnet.dim[2]  dd.sensor.rot=R.from_euler('y', dd.sensor.tilt, degrees=True)
We now turn our attention to the modeling of the mechanical and magnetic aspects. In terms of our functional block diagram, the magnet and sensor are combined into a single block. The corresponding function transforms the input angle θi into a magnetic field vector at the sensor location expressed in the frame of the sensor. Let’s decompose this function into basic Python statements.
we create an instance of a bar magnet with the parameters in the data dictionary: magnet = Bar_magnet(*dd.magnet);
we rotate the magnet by the input angle: magnet.rotate(...);
we calculate the field at the sensor center in the frame of reference B=magnet.getB(...);
we return the magnet to its initial position (to avoid memorizing the state): magnet.angle=0.
we express the vector in the sensor frame accounting for tilt by multiplication by the rotation matrix: dd.sensor.rot.apply(B).
we return the vector components using explicit variable labels to comply with our coding pattern: return {'Bx': B[0], ...}.
We obtain the following code (contrast it to a brute-force implementation in a spreadsheet).
from magpylib.source.magnet import Box as Bar_magnetmagnet=Bar_magnet(**dd.magnet)@apply_func_dfdef calc_B(X, dd, magnet):  'Calculate the field vector in mT as a function of rotation'  magnet.rotate(angle=X.theta_i, axis=[0,0,1])  B = magnet.getB(np.array(dd.sensor.pos))/1e-3  magnet.angle=0  B=dd.sensor.rot.apply(B)  return {'Bx': B[0], 'By': B[1], 'Bz': B[2]}
Note that magnet instance is created once for all outside the function, and passed as an argument to avoid the creating overhead in each function call. The function is wrapped with @apply_fun_df like in our previous article [1] to make it data frame compatible. This way, the function operates directly on a tabular dataset representing the whole angle sweep at once without explicit loop. With this code in place, we can experiment with this functional block, and for example plot the Bx and By component of the field for a complete rotation:
df = pd.DataFrame({'theta_i': np.linspace(0,360,2**10, endpoint=False)})df.hvplot(x='theta_i', y=['Bx', 'By', 'B magn.'],          xlabel='θi [°]', ylabel='Field [mT]')
To model noise, we inject two pink noise sequences into the two sensed field components. The two noise sequences are two different realizations from the same statistical model. In practice, this would correspond to the case where the two components are amplified by two separate chain each with its own noise source. Functionally this is expressed as a transformation from the pair of noiseless components (Bx,By) into noisy components (Bx’,By’). This function can be decomposed as follow:
we generate two independent pink noise sequences (with same length as the stimuli) using the library colorednoise . By default, they have a normalized RMS amplitude of 1;
we scale both sequences by desired RMS amplitude (a parameter from the data dictionary);
we add them to the noiseless signals.
Here is the corresponding code. Note that the transformation is done in-place for convenience (the noiseless components are overwritten).
import colorednoise as cndef apply_noise(df, dd):  noise=pd.DataFrame([pd.Series(cn.powerlaw_psd_gaussian(1, len(df))) for k in (0,1)]).T  noise*=noise*dd['sensor']['noise']  df[['Bx', 'By']] +=noise.values  return df
For the rest of the chain we proceed in a similar fashion. The two blocks denoted G_k represent just scaling gain factors, and are ignored here (G_k=1). The ADC function is just a rounding operation. We are left with the angle calculation using the CORDIC algorithm. We reuse the algorithm in this library [7] with n iterations (parameter from the data dictionary) and in particular the arctan_cordic function working in a single quadrant. To generalize to the four quadrants, we use the formula in wikipedia for calculating arctan2 from arctan using the sign functions. We obtain:
@apply_func_dfdef calc_angle(X, dd):  x,y = X.Qx, X.Qy # convenient alias  angle = cordic.arctan_cordic(x, y, n=dd.cordic.n)  # 4-quadrant extension  return {'theta_o': np.degrees(np.sign(x)**2 * angle + (1-np.sign(x))/2*(1+np.sign(y)-np.sign(y)**2)*np.pi)}
The complete chain, apart from the analysis block, is now modeled. We wrap the complete simulation run into a virtual_experiment function for modularity. This function performs an end-to-end experiment (setup the parameters, run all blocks in sequence, and measure the angle error). It takes as argument an angle sweep in a data frame, and potential changes to the data dictionary. It returns the populated data frame with all intermediate signals and naturally the output angle. This function proceeds as follows:
we first build the default data dictionary;
we merge the changes passed as argument;
we chain the block function using into a pipeline calling each function in turn enriching the data frame of signals;
we perform a quick calculation to extract the angle non-linearity error (wrapped inside ±180° for easy interpretation) corrected for the mean (which does not contribute to the non-linearity).
Here is the corresponding code. And note the correspondence with the block diagram.
def virtual_experiment(theta_i=np.arange(0,360,0.1), dd={}):  dd=merge(load_dd(), dd)  calc_derived(dd)  magnet=Bar_magnet(**dd.magnet)    df = pd.DataFrame({'theta_i': theta_i})  df=(df.pipe(calc_B, dd=dd, magnet=magnet)      .pipe(transduce,dd=dd)      .pipe(apply_noise, dd=dd)      .pipe(quantize, dd=dd)      .pipe(calc_angle, dd=dd))  df['theta_err'] = deg_180(df.theta_o - df.theta_i)  df['theta_err'] -= df['theta_err'].mean()  return df
A performance metric to quantify the dynamic error with a compelling interpretation is the effective number of bits (ENOB). See [8] and [9] for the exact definition. As the name indicates, the metric provides the effective number of bits of the overall transformation from analog input to digital output. For example, ENOB=10b indicates the error has the same magnitude than an ideal 10-bit conversion.
By definition, this metric requires a sinewave stimuli of full scale amplitude: θi = 180° sin(2πft). The frequency f is adjusted to cover an integer number of periods in 2N samples. The digital output θo waveform is then analyzed by a FFT. The engineering interpretation is that the coefficients of the FFT (the so-called spectrum) correspond to the power of the signal in each frequency bin. Naturally there is a peak of power at the input frequency: this is the power S of the signal of interest. The distortions, with total power D, show up at the harmonic frequencies. The rest of the power N is the random noise which broadly fills the other frequency bins, establishing the “noise floor”. In Python, such analysis is concisely invoked with:
f,P = signal.periodogram(df.theta_o, scaling='spectrum')idx_max=np.argmax(P)S   = P[idx_max]    D   = sum(P[2*idx_max:11*idx_max:idx_max]) # sum of harmonic components up to order 10N   = sum(P) - S - D
Again note the convenience of having high-level libraries such as scipy providing the periodogram analysis function.
As usual, for modularity, we wrap the above and the final ENOB calculation in a function ENOB(). The complete analysis is then invoked by calling the virtual experiment and the ENOB function in succession.
The analysis returns the final metrics like ENOB (in number of bits) and the RMS power of the distortion and noise. Before sharing our analysis, we should illustrate it with a few insightful plots. Here we simply plot the input stimuli, the output error and the spectrum. We use another library panel to combine these plots and a summary table in an interactive dashboard.
In areal engineering analysis, one would of course dissect these results, and suggest design improvements. This is out of scope here and instead we wrap up by publishing the analysis.
The analysis is performed inside a Jupyter notebook: the analysis code, the plots, and the narrative is then one entity: a reproducible report. Naturally the report can be exported to standard static formats like HTML or PDF. However there is real benefit to share a “live” instance of the report. This way, the computations can be reproduced. Beyond reproduction, one can experiment with new set of parameters, and the report is updated on the fly. This promotes collaborative learning. The live instance of the report for this analysis is available there (with the computation platform courtesy of https://mybinder.org/).
We presented an illustrative analysis of a hardware signal chain in Python. Starting from the block diagram, and the identification of the error sources, we developed a corresponding Python model. We used a modular coding pattern closely following the structure of the block diagram. We showed how this model can be invoked to run virtual experiments and perform in-depth signal analysis (e.g. analysis of distortion and noise). Finally, we presented our results in the form of a publication-quality dashboard, and reproducible report. Overall, this end-to-end tutorial demonstrated the capabilities of Python for performing hardware engineering design tasks.
[1] G. Close, “Functional modeling and quantitative system analysis in python,” Towards Data Science, 31-Jan-2021. Available: https://towardsdatascience.com/functional-modeling-and-quantitative-system-analysis-in-python-22b90bf0b9b5.
[2] R. Dronzek, “Simulation Modeling for Systems Engineers,” INCOSE Webminar, 10-Jul-2019. Available: https://www.anylogic.com/upload/webinars/al-webinar-simulation-modeling-for-systems-engineers.pdf.
[3] N. Dupré, Y. Bidaux, O. Dubrulle, and G. Close, “A Stray-Field-Immune Magnetic Displacement Sensor with 1% Accuracy,” IEEE Sens. J., May 2020. Available: https://doi.org/10.1109/JSEN.2020.2998289
[4] R. Ravaud and G. Lemarquand, “Magnetic Field Produced by a Parallelepipedic Magnet of Various and Uniform Polarization,” Progress In Electromagnetics Research, vol. 98, pp. 207–219, 2009. Available: http://dx.doi.org/10.2528/PIER09091704.
[5] M. Ortner and L. G. Coliado Bandeira, “Magpylib: A free Python package for magnetic field computation,” SoftwareX, vol. 11, p. 100466, Jan.-2020. Available: http://www.sciencedirect.com/science/article/pii/S2352711020300170
[6] F. Patzelt and Alex Spaeth, “colorednoise.py”, v1.1.1, 01-Jun-2019. Available: https://github.com/felixpatzelt/colorednoise
[7]: John Burkardt, “CORDIC”, 28-Jun-2017. Available: https://people.sc.fsu.edu/~jburkardt/py_src/cordic/cordic.html. [Accessed: 15-Feb-2021]
[8] W. Kester, “Understand SINAD, ENOB, SNR, THD, THD + N, and SFDR so You Don’t Get Lost in the Noise Floor”, Analog Devices, MT-003 Tutorial, 2009. Available: https://www.analog.com/media/en/training-seminars/tutorials/MT-003.pdf.
[9]: robustcircuitdesign.com, “Understanding Effective Number of Bits”, 18-Nov-2016. Available: https://robustcircuitdesign.com/signal-chain-explorer/understanding-effective-number-of-bits/ [Accessed: 15-Feb-2021]