In [1]:
 

$$\require{color} \require{cancel} \def\tf#1{{\mathrm{FT}\left\{ #1 \right\}}} \def\flecheTF{\rightleftharpoons } \def\TFI#1#2#3{{\displaystyle{\int_{-\infty}^{+\infty} #1 ~e^{j2\pi #2 #3} ~\dr{#2}}}} \def\TF#1#2#3{{\displaystyle{\int_{-\infty}^{+\infty} #1 ~e^{-j2\pi #3 #2} ~\dr{#2}}}} \def\sha{ш} \def\dr#1{\mathrm{d}#1} \def\egalpardef{\mathop{=}\limits^\triangle} \def\sinc#1{{\mathrm{sinc}\left( #1 \right)}} \def\rect{\mathrm{rect}} \definecolor{lightred}{rgb}{1,0.1,0} \def\myblueeqbox#1{{\fcolorbox{blue}{lightblue}{$ extcolor{blue}{ #1}$}}} \def\myeqbox#1#2{{\fcolorbox{#1}{light#1}{$ extcolor{#1}{ #2}$}}} \def\eqbox#1#2#3#4{{\fcolorbox{#1}{#2}{$\textcolor{#3}{ #4}$}}} % border|background|text \def\eqboxa#1{{\boxed{#1}}} \def\eqboxb#1{{\eqbox{green}{white}{green}{#1}}} \def\eqboxc#1{{\eqbox{blue}{white}{blue}{#1}}} \def\eqboxd#1{{\eqbox{blue}{lightblue}{blue}{#1}}} \def\E#1{\mathbb{E}\left[#1\right]} \def\ta#1{\left<#1\right>} \def\egalparerg{{\mathop{=}\limits_\mathrm{erg}}} \def\expo#1{\exp\left(#1\right)} \def\d#1{\mathrm{d}#1} \def\wb{\mathbf{w}} \def\sb{\mathbf{s}} \def\xb{\mathbf{x}} \def\Rb{\mathbf{R}} \def\rb{\mathbf{r}} \def\mystar{{*}} \def\ub{\mathbf{u}} \def\wbopt{\mathop{\mathbf{w}}\limits^\triangle} \def\deriv#1#2{\frac{\mathrm{d}#1}{\mathrm{d}#2}} \def\Ub{\mathbf{U}} \def\db{\mathbf{d}} \def\eb{\mathbf{e}} \def\vb{\mathbf{v}} \def\Ib{\mathbf{I}} \def\Vb{\mathbf{V}} \def\Lambdab{\mathbf{\Lambda}} \def\Ab{\mathbf{A}} \def\Bb{\mathbf{B}} \def\Cb{\mathbf{C}} \def\Db{\mathbf{D}} \def\Kb{\mathbf{K}} \def\sinc#1{\mathrm{sinc\left(#1\right)}} $$


Pole-zero locations and transfer functions behavior

In [2]:
from zerospolesdisplay import ZerosPolesDisplay

Let $H(z)$ be a rational fraction $$ H(z) = \frac{N(z)}{D(z)}, $$ where both $N(z)$ and $D(z)$ are polynomials in $z$, with $z\in\mathbb{C}$. The roots of both polynomials are very important in the behavior of $H(z)$.

\begin{definition} The roots of the numerator $N(z)$ are called the **zeros** of the transfer function. The roots of the denominator $N(z)$ are called the **poles** of the transfer function. \end{definition}

Note that poles can occur at $z=\infty$. Recall that for $z=\exp({j2\pi f})$, the Z-transform reduces to the Fourier transform: $$ H(z=e^{j2\pi f})=H(f). $$

\begin{example} Examples of rational fractions - The rational fraction $$ H(z)= \frac{1}{1-az^{-1}} $$ has a pole for $z=a$ and a zero for $z=0$. - The rational fraction $$ H(z)= \frac{1-cz^{-1}}{(1-az^{-1})(1-bz^{-1})} $$ has two poles for $z=a$ and $z=b$ and two zeros, for $z=0$ and $z=c$. - The rational fraction $$ H(z)= \frac{1-z^{-N}}{1-z^{-1}} $$ has $N-1$ zeros of the form $z=\exp(j2\pi k/N)$, for $k=1..N$. Actually there are $N$ roots for the numerator and one root for the denominator, but the common root $z=1$ cancels. \end{example}
\begin{exercise} Give the difference equations corresponding to the previous examples, and compute the inverse Z-transforms (impulse responses). In particular, show that for the last transfer function, the impulse response is $\mathrm{rect}_N(n)$. \end{exercise}\begin{prop} For any polynomial with real coefficients, the roots are either reals or appear by complex conjugate pairs. \end{prop}\begin{proof} Let $$P(z) = \sum_{k=0}^{L-1} p_k z^k.$$ If $z_0=\rho e^{j\theta} $ is a root of the polynom, then $$P(z_0) = \sum_{k=0}^{L-1} p_k \rho^k e^{-jk\theta},$$ and $$P(z_0^*) = \sum_{k=0}^{L-1} p_k \rho^k e^{jk\theta}.$$ Putting $e^{-j(L-1)\theta}$ in factor, we get $$P(z_0^*) = e^{-j(L-1)\theta} \sum_{k=0}^{L-1} p_k \rho^k e^{jk\theta}= e^{-j(L-1)\theta} P(z_0)=0.$$ \end{proof}

This shows that if the coefficients of the transfer function are real, then the zeros and poles are either real or appear in complex conjugate pairs. This is usually the case, since these coefficients are the coefficients of the underlying difference equation. For real filters, the difference equation has obviously real coefficients.

\begin{textboxa} For real filters, the zeros and poles are either real or appear in complex conjugate pairs. \end{textboxa}

Analysis of no-pole transfer functions

Suppose that a transfer function $H(z)=N(z)$ has two conjugated zeros $z_0$ and $z_0^*$ of the form $\rho e^{\pm j\theta}$. That is, $N(z)$ has the form

\begin{align} N(z) & = (z-z_0)(z-z_0^*) = (z-\rho e^{ j\theta})(z-\rho e^{- j\theta}) \\ & = z^2 -2\rho\cos(\theta)z + \rho^2 \end{align}

For a single zero, we see that the transfer function, with $z=e^{j2\pi f}$, is minimum when $|z-z_0|$ is minimum. Since $|z-z_0|$ can be interpreted as the distance between points $z$ and $z_0$ in the complex plane, this happens when $z$ and $z_0$ have the same phase (frequency). When $z_0$ has a modulus one, that is is situated on the unit circle, then $z-z_0$ will be null for this frequency and the function transfer will present a null.

In [3]:
%matplotlib inline
poles=np.array([0])
zeros=np.array([0.85*np.exp(1j*2*pi*0.2)])
A=ZerosPolesDisplay(poles,zeros)
figcaption("Poles-Zeros representation, Transfer function and Impulse response for a single zero", label="fig:singlezero")
/usr/local/lib/python3.5/site-packages/matplotlib/tight_layout.py:199: UserWarning: Tight layout not applied. tight_layout cannot make axes width small enough to accommodate all axes decorations
  warnings.warn('Tight layout not applied. '
Caption: Poles-Zeros representation, Transfer function and Impulse response for a single zero
In [4]:
poles=np.array([0])
zeros=np.array([0.95*np.exp(1j*2*pi*0.4)])
A=ZerosPolesDisplay(poles,zeros)
figcaption("Poles-Zeros representation, Transfer function and Impulse response for a single zero", label="fig:singlezero_2")
/usr/local/lib/python3.5/site-packages/matplotlib/tight_layout.py:199: UserWarning: Tight layout not applied. tight_layout cannot make axes width small enough to accommodate all axes decorations
  warnings.warn('Tight layout not applied. '
Caption: Poles-Zeros representation, Transfer function and Impulse response for a single zero

With two or more zeros, the same kind of observations holds. However,because of the interactions berween the zeros, the minimum no more strictly occur for the frequencies of the zeros but for some close frequencies.

This is illustrated now in the case of two complex-conjugated zeros (which corresponds to a transfer function with real coefficients).

In [5]:
poles=np.array([0])
zeros=np.array([0.95*np.exp(1j*2*pi*0.2), 0.95*np.exp(-1j*2*pi*0.2)])
A=ZerosPolesDisplay(poles,zeros)
figcaption("Poles-Zeros representation, Transfer function and Impulse response for a double zero", label="fig:doublezero")
/usr/local/lib/python3.5/site-packages/matplotlib/tight_layout.py:199: UserWarning: Tight layout not applied. tight_layout cannot make axes width small enough to accommodate all axes decorations
  warnings.warn('Tight layout not applied. '
Caption: Poles-Zeros representation, Transfer function and Impulse response for a double zero
In [6]:
poles=np.array([0])
zeros=np.array([0.95*np.exp(1j*2*pi*0.2), 0.95*np.exp(-1j*2*pi*0.2), 0.97*np.exp(1j*2*pi*0.3), 0.97*np.exp(-1j*2*pi*0.3)])
A=ZerosPolesDisplay(poles,zeros)
figcaption("Poles-Zeros representation, Transfer function and Impulse response for a 4 zeros", label="fig:doublezero2")
/usr/local/lib/python3.5/site-packages/matplotlib/tight_layout.py:199: UserWarning: Tight layout not applied. tight_layout cannot make axes width small enough to accommodate all axes decorations
  warnings.warn('Tight layout not applied. '
Caption: Poles-Zeros representation, Transfer function and Impulse response for a 4 zeros

Analysis of all-poles transfer functions

For an all-pole transfer function, $$ H(z)=\frac{1}{D(z)} $$ we will obviously have the inverse behavior. Instead of an attenuation at a frequency close to the value given by the angle of the root, we will obtain a surtension. This is illustrated below, in the case of a single, the multiple poles.

In [7]:
zeros=np.array([0])
poles=np.array([0.85*np.exp(1j*2*pi*0.2)])
A=ZerosPolesDisplay(poles,zeros)
figcaption("Poles-Zeros representation, Transfer function and Impulse response for a single pole", label="fig:singlepole")
/usr/local/lib/python3.5/site-packages/matplotlib/tight_layout.py:199: UserWarning: Tight layout not applied. tight_layout cannot make axes width small enough to accommodate all axes decorations
  warnings.warn('Tight layout not applied. '
Caption: Poles-Zeros representation, Transfer function and Impulse response for a single pole

Furthermore, we see that

\begin{textboxa} the closer the pole to the unit circle, the more important the surtension \end{textboxa}
In [8]:
zeros=np.array([0])
poles=np.array([0.97*np.exp(1j*2*pi*0.2)])
A=ZerosPolesDisplay(poles,zeros)
figcaption("Poles-Zeros representation, Transfer function and Impulse response for a single pole", label="fig:singlepole_2")
/usr/local/lib/python3.5/site-packages/matplotlib/tight_layout.py:199: UserWarning: Tight layout not applied. tight_layout cannot make axes width small enough to accommodate all axes decorations
  warnings.warn('Tight layout not applied. '
Caption: Poles-Zeros representation, Transfer function and Impulse response for a single pole

We can also remark that if the modulus of the pole becomes higher than one, then the impulse response diverges. The system is no more stable.

\begin{textboxa} Poles with a modulus higher than one yields an instable system. \end{textboxa}
In [9]:
zeros=np.array([0])
poles=np.array([1.1*np.exp(1j*2*pi*0.2)])
A=ZerosPolesDisplay(poles,zeros)
figcaption("Poles-Zeros representation, Transfer function and Impulse response for a single pole", label="fig:singlepole_3")
/usr/local/lib/python3.5/site-packages/matplotlib/tight_layout.py:199: UserWarning: Tight layout not applied. tight_layout cannot make axes width small enough to accommodate all axes decorations
  warnings.warn('Tight layout not applied. '
Caption: Poles-Zeros representation, Transfer function and Impulse response for a single pole

For 4 poles, we get the following: two pairs of surtensions, for frequencies essentialy given by the arguments of the poles.

In [10]:
zeros=np.array([0])
poles=np.array([0.95*np.exp(1j*2*pi*0.2), 0.95*np.exp(-1j*2*pi*0.2), 0.97*np.exp(1j*2*pi*0.3), 0.97*np.exp(-1j*2*pi*0.3)])
A=ZerosPolesDisplay(poles,zeros)
figcaption("Poles-Zeros representation, Transfer function and Impulse response for a 4 poles", label="fig:doublepoles2")
/usr/local/lib/python3.5/site-packages/matplotlib/tight_layout.py:199: UserWarning: Tight layout not applied. tight_layout cannot make axes width small enough to accommodate all axes decorations
  warnings.warn('Tight layout not applied. '
Caption: Poles-Zeros representation, Transfer function and Impulse response for a 4 poles

General transfer functions

For a general transfer function, we will get the combination of the two effects: attenuation or even nulls that are given by the zeros, and sutensions, maxima that are yied by the poles. Here is a simple example.

In [11]:
poles=np.array([0.85*np.exp(1j*2*pi*0.2), 0.85*np.exp(-1j*2*pi*0.2)])
zeros=np.array([1.1*np.exp(1j*2*pi*0.3), 1.1*np.exp(-1j*2*pi*0.3)])
A=ZerosPolesDisplay(poles,zeros)
figcaption("Poles-Zeros representation, Transfer function and Impulse response for a 2 poles and 2 zeros", label="fig:poleszero")
/usr/local/lib/python3.5/site-packages/matplotlib/tight_layout.py:199: UserWarning: Tight layout not applied. tight_layout cannot make axes width small enough to accommodate all axes decorations
  warnings.warn('Tight layout not applied. '
Caption: Poles-Zeros representation, Transfer function and Impulse response for a 2 poles and 2 zeros

Hence we see that it is possible to understand the behavior of transfer function by studying the location of their poles and zeros. It is even possible to design transfer function by optimizing the placement of their poles and zeros.

For instance, given the poles and zeros in the previous example, we immediately find the coefficients of the filter by computing the corresponding polynomials:

In [12]:
print("poles",A.poles)
print("zeros",A.zeros)

print("coeffs a:",np.poly(A.poles))
print("coeffs b:",np.poly(A.zeros))
poles [0.26266445+0.80839804j 0.26266445-0.80839804j]
zeros [-0.33991869+1.04616217j -0.33991869-1.04616217j]
coeffs a: [ 1.         -0.52532889  0.7225    ]
coeffs b: [1.         0.67983739 1.21      ]
\begin{textboxa} In order to further investigate these properties and experiment with the pole and zeros placement, your servant has prepared a ZerosPolesPlay class. **Enjoy!** \end{textboxa}
In [13]:
%matplotlib
%run zerospolesplay.py
Using matplotlib backend: TkAgg
/usr/local/lib/python3.5/site-packages/matplotlib/tight_layout.py:199: UserWarning: Tight layout not applied. tight_layout cannot make axes width small enough to accommodate all axes decorations
  warnings.warn('Tight layout not applied. '

Appendix -- listing of the class ZerosPolesPlay

In [14]:
# %load zerospolesplay.py
In [15]:
"""
Transfer function adjustment using zeros and poles drag and drop!
jfb 2015 - last update november 2018
"""
import numpy as np
import matplotlib.pyplot as plt
from numpy import pi

#line, = ax.plot(xs, ys, 'o', picker=5)  # 5 points tolerance


class ZerosPolesPlay():
    def __init__(self,
                 poles=np.array([0.7 * np.exp(1j * 2 * np.pi * 0.1)]),
                 zeros=np.array([1.27 * np.exp(1j * 2 * np.pi * 0.3)]),
                 N=1000,
                 response_real=True,
                 ymax=1.2,
                 Nir=64):

        if response_real:
            self.poles, self.poles_isreal = self.sym_comp(poles)
            self.zeros, self.zeros_isreal = self.sym_comp(zeros)
        else:
            self.poles = poles
            self.poles_isreal = (np.abs(np.imag(poles)) < 1e-12)
            self.zeros = zeros
            self.zeros_isreal = (np.abs(np.imag(zeros)) < 1e-12)

        self.ymax = np.max([
            ymax, 1.2 * np.max(np.concatenate((np.abs(poles), np.abs(zeros))))
        ])
        self.poles_th = np.angle(self.poles)
        self.poles_r = np.abs(self.poles)
        self.zeros_th = np.angle(self.zeros)
        self.zeros_r = np.abs(self.zeros)
        self.N = N
        self.Nir = Nir
        self.response_real = response_real

        self.being_dragged = None
        self.nature_dragged = None
        self.poles_line = None
        self.zeros_line = None
        self.setup_main_screen()
        self.connect()
        self.update()

    def setup_main_screen(self):

        import matplotlib.gridspec as gridspec

        #Poles & zeros
        self.fig = plt.figure()
        gs = gridspec.GridSpec(3, 12)
        #self.ax = self.fig.add_axes([0.1, 0.1, 0.77, 0.77], polar=True, facecolor='#d5de9c')
        #self.ax=self.fig.add_subplot(221,polar=True, facecolor='#d5de9c')
        self.ax = plt.subplot(gs[0:, 0:6], polar=True, facecolor='#d5de9c')
        #self.ax = self.fig.add_subplot(111, polar=True)
        self.fig.suptitle(
            'Poles & zeros adjustment',
            fontsize=18,
            color='blue',
            x=0.1,
            y=0.98,
            horizontalalignment='left')
        #self.ax.set_title('Poles & zeros adjustment',fontsize=16, color='blue')
        self.ax.set_ylim([0, self.ymax])
        self.poles_line, = self.ax.plot(
            self.poles_th, self.poles_r, 'ob', ms=9, picker=5, label="Poles")
        self.zeros_line, = self.ax.plot(
            self.zeros_th, self.zeros_r, 'Dr', ms=9, picker=5, label="Zeros")
        self.ax.plot(
            np.linspace(-np.pi, np.pi, 500), np.ones(500), '--b', lw=1)
        self.ax.legend(loc=1)

        #Transfer function
        #self.figTF, self.axTF = plt.subplots(2, sharex=True)
        #self.axTF0=self.fig.add_subplot(222,facecolor='LightYellow')
        self.axTF0 = plt.subplot(gs[0, 6:11], facecolor='LightYellow')
        #self.axTF[0].set_axis_bgcolor('LightYellow')
        self.axTF0.set_title('Transfer function (modulus)')
        #self.axTF1=self.fig.add_subplot(224,facecolor='LightYellow')
        self.axTF1 = plt.subplot(gs[1, 6:11], facecolor='LightYellow')
        self.axTF1.set_title('Transfer function (phase)')
        self.axTF1.set_xlabel('Frequency')
        f = np.linspace(0, 1, self.N)
        self.TF = np.fft.fft(np.poly(self.zeros), self.N) / np.fft.fft(
            np.poly(self.poles), self.N)
        self.TF_m_line, = self.axTF0.plot(f, np.abs(self.TF))
        self.TF_p_line, = self.axTF1.plot(f, 180 / np.pi * np.angle(self.TF))
        #self.figTF.canvas.draw()

        #Impulse response
        #self.figIR = plt.figure()
        #self.axIR = self.fig.add_subplot(223,facecolor='Lavender')
        self.axIR = plt.subplot(gs[2, 6:11], facecolor='Lavender')
        self.IR = self.impz(self.zeros, self.poles,
                            self.Nir)  #np.real(np.fft.ifft(self.TF))
        self.axIR.set_title('Impulse response')
        self.axIR.set_xlabel('Time')
        self.IR_m_line, = self.axIR.plot(self.IR)
        #self.figIR.canvas.draw()        
        self.fig.canvas.draw()
        self.fig.tight_layout()

    def impz(self, zeros, poles, L):
        from scipy.signal import lfilter
        a = np.poly(poles)
        b = np.poly(zeros)
        d = np.zeros(L)
        d[0] = 1
        h = lfilter(b, a, d)
        return h

    def sym_comp(self, p):
        L = np.size(p)
        r = list()
        c = list()
        for z in p:
            if np.abs(np.imag(z)) < 1e-12:
                r.append(z)
            else:
                c.append(z)
        out = np.concatenate((c, r, np.conjugate(c[::-1])))
        isreal = (np.abs(np.imag(out)) < 1e-12)
        return out, isreal
#sym_comp([1+1j, 2, 3-2j])

    def connect(self):
        self.cidpick = self.fig.canvas.mpl_connect('pick_event', self.on_pick)
        self.cidrelease = self.fig.canvas.mpl_connect('button_release_event',
                                                      self.on_release)
        self.cidmotion = self.fig.canvas.mpl_connect('motion_notify_event',
                                                     self.on_motion)

    def update(self):

        #poles and zeros
        #self.fig.canvas.draw()

        #Transfer function & Impulse response
        if not (self.being_dragged is None):
            #print("Was released")

            f = np.linspace(0, 1, self.N)
            self.TF = np.fft.fft(np.poly(self.zeros), self.N) / np.fft.fft(
                np.poly(self.poles), self.N)
            self.TF_m_line.set_ydata(np.abs(self.TF))
            M = np.max(np.abs(self.TF))
            #update the yscale
            current_ylim = self.axTF0.get_ylim()[1]
            if M > current_ylim or M < 0.5 * current_ylim:
                self.axTF0.set_ylim([0, 1.2 * M])

            #phase
            self.TF_p_line.set_ydata(180 / np.pi * np.angle(self.TF))
            #self.figTF.canvas.draw()

            # Impulse response
            self.IR = self.impz(self.zeros, self.poles,
                                self.Nir)  #np.fft.ifft(self.TF)
            #print(self.IR)
            self.IR_m_line.set_ydata(self.IR)
            M = np.max(self.IR)
            Mm = np.min(self.IR)
            #update the yscale
            current_ylim = self.axIR.get_ylim()
            update_ylim = False
            if M > current_ylim[1] or M < 0.5 * current_ylim[1]:
                update_ylim = True
            if Mm < current_ylim[0] or np.abs(Mm) > 0.5 * np.abs(current_ylim[
                    0]):
                update_ylim = True
            if update_ylim: self.axIR.set_ylim([Mm, 1.2 * M])

            #self.figIR.canvas.draw()
        self.fig.canvas.draw()

    def on_pick(self, event):
        """When we click on the figure and hit either the line or the menu items this gets called."""
        if event.artist != self.poles_line and event.artist != self.zeros_line:
            return
        self.being_dragged = event.ind[0]
        self.nature_dragged = event.artist

    def on_motion(self, event):
        """Move the selected points and update the graphs."""
        if event.inaxes != self.ax: return
        if self.being_dragged is None: return
        p = self.being_dragged  #index of points on the line being dragged
        xd = event.xdata
        yd = event.ydata
        #print(yd)
        if self.nature_dragged == self.poles_line:
            x, y = self.poles_line.get_data()
            if not (self.poles_isreal[p]):
                x[p], y[p] = xd, yd
            else:
                if np.pi / 2 < xd < 3 * np.pi / 2:
                    x[p], y[p] = np.pi, yd
                else:
                    x[p], y[p] = 0, yd
            x[-p - 1], y[-p - 1] = -x[p], y[p]
            self.poles_line.set_data(x, y)  # then update the line
            #print(self.poles)
            self.poles[p] = y[p] * np.exp(1j * x[p])
            self.poles[-p - 1] = y[p] * np.exp(-1j * x[p])

        else:
            x, y = self.zeros_line.get_data()
            if not (self.zeros_isreal[p]):
                x[p], y[p] = xd, yd
            else:
                if np.pi / 2 < xd < 3 * np.pi / 2:
                    x[p], y[p] = np.pi, yd
                else:
                    x[p], y[p] = 0, yd
            x[-p - 1], y[-p - 1] = -x[p], y[p]
            self.zeros_line.set_data(x, y)  # then update the line
            self.zeros[p] = y[p] * np.exp(1j * x[p])  # then update the line
            self.zeros[-p - 1] = y[p] * np.exp(-1j * x[p])

        self.update()  #and the plot

    def on_release(self, event):
        """When we release the mouse, if we were dragging a point, recompute everything."""
        if self.being_dragged is None: return

        self.being_dragged = None
        self.nature_dragged = None
        self.update()


#case of complex poles and zeros
poles = np.array(
    [0.8 * np.exp(1j * 2 * pi * 0.125), 0.8 * np.exp(1j * 2 * pi * 0.15), 0.5])
zeros = np.array(
    [0.95 * np.exp(1j * 2 * pi * 0.175), 1.4 * np.exp(1j * 2 * pi * 0.3), 0])
A = ZerosPolesPlay(poles, zeros)
"""
#case of a single real pole
poles=np.array([0.5])
zeros=np.array([0])
A=ZerosPolesPlay(poles,zeros,response_real=False)
"""

plt.show()

#At the end, poles and zeros available as A.poles and A.zeros
/usr/local/lib/python3.5/site-packages/matplotlib/tight_layout.py:199: UserWarning: Tight layout not applied. tight_layout cannot make axes width small enough to accommodate all axes decorations
  warnings.warn('Tight layout not applied. '