The Z-Domain

Transfer functions, poles, zeros, and stability

In Chapter 2, we described filters by their difference equations and impulse responses, all in the time domain. This works fine for FIR filters, but for IIR filters with feedback, solving the difference equation directly can be painful. A first-order IIR filter is manageable; a fourth-order one is a wall of algebra.

The z-transform sidesteps this problem. It converts sequences and difference equations into polynomials, turning convolution into multiplication and time-delays into powers of \(z^{-1}\). The result is a compact algebraic representation, the transfer function, that tells you everything about a filter’s behaviour without ever computing a single output sample.


The z-transform

The (unilateral) z-transform (Proakis and Manolakis 2007) converts a discrete-time signal \(x[n]\) into a function of the complex variable \(z\):

\[X(z) = \sum_{n=0}^{\infty} x[n]\, z^{-n} = x[0] + x[1]\,z^{-1} + x[2]\,z^{-2} + \cdots\]

where \(z\) is a complex number: \(z = A\,e^{j\phi}\), with magnitude \(A\) and angle \(\phi\). Notice that \(X(z)\) is a continuous function of \(z\), even though \(x[n]\) is a discrete sequence.

The key insight: each factor of \(z^{-1}\) represents a one-sample delay. So the z-transform replaces time-shifts with algebraic multiplication, which is much easier to manipulate.

A first example

Consider a signal with four samples: \(x[0] = 0.5\), \(x[1] = 3\), \(x[2] = -0.5\), \(x[3] = 1\). Its z-transform is:

\[X(z) = 0.5 + 3\,z^{-1} - 0.5\,z^{-2} + z^{-3}\]

That’s it. Just substitute each sample value at its delay. The signal in the time domain becomes a polynomial in \(z^{-1}\).

Properties

The z-transform has three properties we’ll use constantly:

Property Time domain Z-domain
Linearity \(a_1\,x_1[n] + a_2\,x_2[n]\) \(a_1\,X_1(z) + a_2\,X_2(z)\)
Delay \(x[n-k]\) \(z^{-k}\,X(z)\)
Convolution \(x_1[n] * x_2[n]\) \(X_1(z) \cdot X_2(z)\)

The last one is the big payoff: convolution in time becomes multiplication in the z-domain. Computing a filter’s output, which requires sliding and summing in the time domain, becomes a single polynomial multiplication in the z-domain.

Transform pairs

Rather than computing the infinite sum every time, we use a short table of known pairs:

Signal \(x[n]\) \(X(z)\)
Kronecker delta \(\delta[n]\) \(1\)
Unit step \(u[n]\) \(\dfrac{z}{z-1}\)
Geometric sequence \(a^n\,u[n]\) \(\dfrac{z}{z-a}\)
Delayed signal \(x[n-k]\) \(z^{-k}\,X(z)\)

The unit step \(u[n]\) is defined as \(u[n] = 1\) for \(n \geq 0\) and \(u[n] = 0\) for \(n < 0\). Multiplying any signal by \(u[n]\) enforces causality: the signal is zero before \(n = 0\).

The geometric sequence \(a^n u[n]\) appears everywhere in IIR filter analysis. Its z-transform follows from the definition:

\[\mathcal{Z}\{a^n u[n]\} = \sum_{n=0}^{\infty} a^n z^{-n} = \sum_{n=0}^{\infty} (az^{-1})^n = \frac{1}{1 - az^{-1}} = \frac{z}{z-a}\]

The last step uses the geometric series formula \(\sum_{n=0}^{\infty} r^n = \frac{1}{1-r}\), which converges when \(|r| < 1\), i.e., when \(|az^{-1}| < 1\). This convergence requirement is the region of convergence (ROC); for causal signals, it means \(|z| > |a|\).

Compute the z-transform of \(x[n] = -\delta[n] + 2\,\delta[n-1] - 3\,\delta[n-5]\).

Using linearity and the delay property:

\[X(z) = -1 + 2z^{-1} - 3z^{-5}\]

Each delayed delta contributes a single term at the corresponding power of \(z^{-1}\).


Transfer functions

In Chapter 2, we wrote the general difference equation:

\[y[n] = \sum_{i=0}^{P} b_i\,x[n-i] - \sum_{j=1}^{Q} a_j\,y[n-j]\]

Taking the z-transform of both sides (using the linearity and delay properties) and rearranging gives the transfer function:

\[H(z) = \frac{Y(z)}{X(z)} = \frac{b_0 + b_1\,z^{-1} + \cdots + b_P\,z^{-P}}{1 + a_1\,z^{-1} + \cdots + a_Q\,z^{-Q}}\]

This is the ratio of two polynomials in \(z^{-1}\). The numerator contains the feedforward coefficients \(b_i\); the denominator contains the feedback coefficients \(a_j\) (with \(a_0 = 1\) by convention).

The transfer function is also the z-transform of the impulse response:

\[H(z) = \mathcal{Z}\{h[n]\}\]

And because convolution becomes multiplication: \(Y(z) = H(z) \cdot X(z)\).

Block diagram elements in the z-domain

The three building blocks from Chapter 2 translate directly:

Operation Time domain Z-domain
Addition \(y[n] = x_1[n] + x_2[n]\) \(Y(z) = X_1(z) + X_2(z)\)
Scaling \(y[n] = a\,x[n]\) \(Y(z) = a\,X(z)\)
Unit delay \(y[n] = x[n-1]\) \(Y(z) = z^{-1}\,X(z)\)

The delay element \(T\) in a block diagram is simply replaced by \(z^{-1}\).

Example: 3-point moving average

The MA filter from Chapter 2:

\[y[n] = \tfrac{1}{3}\bigl(x[n] + x[n-1] + x[n-2]\bigr)\]

Taking the z-transform:

\[Y(z) = \tfrac{1}{3}\bigl(X(z) + z^{-1}X(z) + z^{-2}X(z)\bigr) = \frac{X(z)}{3}\bigl(1 + z^{-1} + z^{-2}\bigr)\]

So the transfer function is:

\[H(z) = \frac{Y(z)}{X(z)} = \frac{1 + z^{-1} + z^{-2}}{3} = \frac{z^2 + z + 1}{3z^2}\]

Note: there is no denominator polynomial (beyond the trivial \(z^2\)), confirming this is an FIR filter. For FIR filters, the filter order equals the number of delay elements.

Example: DC blocker (first-order IIR)

The DC-blocking high-pass filter from Chapter 2:

\[y[n] = x[n] - x[n-1] + a\,y[n-1]\]

Taking the z-transform:

\[Y(z) = X(z) - z^{-1}X(z) + a\,z^{-1}Y(z)\]

Collecting terms:

\[Y(z)\bigl(1 - a\,z^{-1}\bigr) = X(z)\bigl(1 - z^{-1}\bigr)\]

\[H(z) = \frac{1 - z^{-1}}{1 - a\,z^{-1}} = \frac{z - 1}{z - a}\]

This has one zero at \(z = 1\) and one pole at \(z = a\). The zero at \(z = 1\) forces \(H(z) = 0\) at DC (zero frequency), which is exactly what a DC blocker should do.

A second-order IIR filter has the difference equation:

\[y[n] = x[n] + b\,x[n-1] + a\,y[n-1] + c\,y[n-2]\]

Derive \(H(z)\).

Taking the z-transform:

\[Y(z) = X(z) + b\,z^{-1}X(z) + a\,z^{-1}Y(z) + c\,z^{-2}Y(z)\]

\[Y(z)\bigl(1 - a\,z^{-1} - c\,z^{-2}\bigr) = X(z)\bigl(1 + b\,z^{-1}\bigr)\]

\[H(z) = \frac{1 + b\,z^{-1}}{1 - a\,z^{-1} - c\,z^{-2}}\]


The z-plane

A transfer function \(H(z)\) is a ratio of two polynomials. The roots of the numerator are the zeros (where \(H(z) = 0\)); the roots of the denominator are the poles (where \(H(z) \to \infty\)). Together with a gain constant \(K\), they completely specify the transfer function:

\[H(z) = K \frac{(z - z_1)(z - z_2) \cdots (z - z_M)}{(z - p_1)(z - p_2) \cdots (z - p_N)}\]

A pole-zero plot shows these locations on the complex z-plane: zeros are marked with \(\circ\), poles with \(\times\). The unit circle (\(|z| = 1\)) is always drawn because poles must lie inside it for stability, and it corresponds to the frequency axis (as we’ll see in Chapter 5).

Example: MA filter poles and zeros

The 3-point MA filter has \(H(z) = (z^2 + z + 1) / (3z^2)\). The numerator roots are the zeros; the denominator gives poles at \(z = 0\) (a double pole at the origin).

b_ma = [1/3, 1/3, 1/3]   # feedforward coefficients
a_ma = [1]                # no feedback (FIR)

fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(10, 4.5))
plot_pzmap(b_ma, a_ma, title='3-point MA filter', ax=ax1)

# Show the impulse response alongside
n = np.arange(10)
h = lfilter(b_ma, a_ma, np.eye(1, 10).flatten())
ax2.stem(n, h, linefmt='k-', markerfmt='ko', basefmt='k-')
ax2.set_xlabel('n')
ax2.set_ylabel('h[n]')
ax2.set_title('Impulse response')
ax2.grid(True, alpha=0.3)
fig.tight_layout()
plt.show()
Figure 1: Pole-zero plot of the 3-point MA filter. Two complex zeros on the unit circle, two poles at the origin.

For FIR filters, the only poles are at \(z = 0\) (trivial poles from the \(z^{-P}\) terms). FIR filters are sometimes said to have “no poles”: this is engineer’s shorthand for “no non-trivial poles.”

Example: DC blocker poles and zeros

The DC blocker with \(a = 0.95\): \(H(z) = (z - 1)/(z - 0.95)\).

b_dc = [1, -1]       # numerator: z - 1
a_dc = [1, -0.95]    # denominator: z - 0.95

fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(10, 4.5))
plot_pzmap(b_dc, a_dc, title='DC blocker (a = 0.95)', ax=ax1)

# Show impulse response
n = np.arange(40)
delta = np.zeros(40)
delta[0] = 1
h = lfilter([1, -1], [1, -0.95], delta)
ax2.stem(n, h, linefmt='k-', markerfmt='ko', basefmt='k-')
ax2.set_xlabel('n')
ax2.set_ylabel('h[n]')
ax2.set_title('Impulse response')
ax2.grid(True, alpha=0.3)
fig.tight_layout()
plt.show()
Figure 2: Pole-zero plot of the DC blocker (a = 0.95). The zero at z = 1 kills DC; the pole at z = 0.95 provides a sharp transition.

The pole at \(z = 0.95\) is inside the unit circle: the system is stable. The impulse response decays as \(0.95^n\), which we can verify by looking at the plot.

Example: second-order IIR filter

Consider a second-order filter with the difference equation \(y[n] = \frac{1}{2}x[n] - \frac{1}{2}x[n-2] - y[n-1] - \frac{1}{2}y[n-2]\), giving the transfer function \(H(z) = (z^2 - 1)/(2z^2 + 2z + 1)\).

b_iir2 = [1, 0, -1]    # numerator: 1 - z^{-2}
a_iir2 = [2, 2, 1]     # denominator: 2 + 2z^{-1} + z^{-2}

fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(10, 4.5))
ax = plot_pzmap(b_iir2, a_iir2, title='Second-order IIR filter', ax=ax1)

# Annotate pole and zero locations
zeros, poles, _ = tf2zpk(b_iir2, a_iir2)
for z in zeros:
    ax.annotate(f'{z.real:.1f}{z.imag:+.1f}j' if z.imag != 0 else f'{z.real:.1f}',
                (z.real, z.imag), textcoords='offset points', xytext=(10, -10), fontsize=8)
for p in poles:
    ax.annotate(f'{p.real:.2f}{p.imag:+.2f}j',
                (p.real, p.imag), textcoords='offset points', xytext=(10, 5), fontsize=8)

# Impulse response
n = np.arange(30)
delta = np.zeros(30)
delta[0] = 1
h = lfilter(b_iir2, a_iir2, delta)
ax2.stem(n, h, linefmt='k-', markerfmt='ko', basefmt='k-')
ax2.set_xlabel('n')
ax2.set_ylabel('h[n]')
ax2.set_title('Impulse response')
ax2.grid(True, alpha=0.3)
fig.tight_layout()
plt.show()
Figure 3: Pole-zero plot of a second-order IIR filter. Complex conjugate poles inside the unit circle: the system is stable.

Notice the oscillating, decaying impulse response: this is the signature of complex conjugate poles. The oscillation frequency is related to the angle of the poles; the decay rate is related to their distance from the unit circle.

Complex conjugate poles

For filters with real coefficients, complex poles always come in conjugate pairs: if \(p = r\,e^{j\theta}\) is a pole, so is \(p^* = r\,e^{-j\theta}\). The same holds for zeros. This is why the upper and lower halves of the z-plane are mirror images.

Python: computing poles and zeros

SciPy’s tf2zpk converts transfer function coefficients to zeros, poles, and gain:

# DC blocker example
b = [1, -1]
a = [1, -0.95]
zeros, poles, gain = tf2zpk(b, a)

print(f"Zeros: {zeros}")
print(f"Poles: {poles}")
print(f"Gain:  {gain}")
Zeros: [1.]
Poles: [0.95]
Gain:  1.0

From the z-domain back to time

The inverse z-transform recovers the time-domain signal:

\[h[n] = \mathcal{Z}^{-1}\{H(z)\}\]

For the filters in this chapter, we can use the transform pairs table directly without formal inversion methods.

FIR: direct readoff

The MA filter transfer function \(H(z) = \frac{1}{3}(1 + z^{-1} + z^{-2})\) inverts immediately using the delta/delay pairs:

\[h[n] = \tfrac{1}{3}\delta[n] + \tfrac{1}{3}\delta[n-1] + \tfrac{1}{3}\delta[n-2]\]

This is just the impulse response we already know from Chapter 2: the filter coefficients themselves.

IIR: using the table

The DC blocker \(H(z) = \frac{z - 1}{z - a}\) requires a bit more work. Split it into recognisable pieces:

\[H(z) = \frac{z}{z-a} - \frac{1}{z-a} = \frac{z}{z-a} - z^{-1}\frac{z}{z-a}\]

Using the geometric sequence pair (\(\frac{z}{z-a} \leftrightarrow a^n u[n]\)) and the delay property:

\[h[n] = a^n\,u[n] - a^{n-1}\,u[n-1]\]

Let’s verify this with Python:

a_val = 0.8
n = np.arange(20)

# Closed-form from inverse z-transform
h_formula = a_val**n - np.where(n >= 1, a_val**(n-1), 0)

# Numerical: apply the filter to a delta
delta = np.zeros(20)
delta[0] = 1
h_filter = lfilter([1, -1], [1, -a_val], delta)

fig, ax = plt.subplots(figsize=(10, 3))
ax.plot(n, h_formula, 'r-', linewidth=2, alpha=0.6, label='Closed-form: $a^n u[n] - a^{n-1} u[n-1]$')
ax.stem(n, h_filter, linefmt='k-', markerfmt='ko', basefmt='k-', label='lfilter (numerical)')
ax.set_xlabel('n')
ax.set_ylabel('h[n]')
ax.set_title(f'DC blocker impulse response (a = {a_val})')
ax.legend(fontsize=8)
ax.grid(True, alpha=0.3)
fig.tight_layout()
plt.show()
Figure 4: IIR impulse response: computed from the difference equation (dots) vs the closed-form expression (line).

The two approaches give identical results: the z-transform is a faithful representation.

Partial fraction decomposition

For higher-order transfer functions, you can’t just read off the inverse from the table. Partial fraction decomposition (Mitra 2006) breaks \(H(z)\) into a sum of simple terms, each of which has a known inverse.

The method: given \(H(z)\) with distinct poles at \(p_1, p_2, \ldots\), divide by \(z\) first, then decompose:

\[\frac{H(z)}{z} = \frac{A_1}{z - p_1} + \frac{A_2}{z - p_2} + \cdots\]

Multiply both sides by \(z\) again, and each term \(\frac{A_i\,z}{z - p_i}\) inverts to \(A_i\,p_i^n\,u[n]\) using the geometric sequence pair.

Worked example. Consider a second-order system:

\[H(z) = \frac{z}{(z - 0.5)(z - 0.8)}\]

Divide by \(z\): \(\frac{H(z)}{z} = \frac{1}{(z - 0.5)(z - 0.8)}\). Using cover-up:

\[A_1 = \frac{1}{z - 0.8}\bigg|_{z=0.5} = \frac{1}{-0.3} = -\frac{10}{3}, \qquad A_2 = \frac{1}{z - 0.5}\bigg|_{z=0.8} = \frac{1}{0.3} = \frac{10}{3}\]

So \(H(z) = \frac{-10/3 \cdot z}{z - 0.5} + \frac{10/3 \cdot z}{z - 0.8}\), giving:

\[h[n] = \frac{10}{3}\bigl(0.8^n - 0.5^n\bigr)\,u[n]\]

SciPy can verify this with residuez:

from scipy.signal import residuez

# H(z) = z / [(z-0.5)(z-0.8)] = z^{-1} / [(1-0.5z^{-1})(1-0.8z^{-1})]
# In scipy convention: b = [0, 1], a = conv([1,-0.5],[1,-0.8])
b = [0, 1]
a = np.convolve([1, -0.5], [1, -0.8])
r, p, k = residuez(b, a)
print(f"Residues: {r}")
print(f"Poles:    {p}")
print(f"Direct:   {k}")

# Closed-form
n = np.arange(20)
h_formula = (10/3) * (0.8**n - 0.5**n)

# Numerical
delta = np.zeros(20); delta[0] = 1
h_numerical = lfilter(b, a, delta)

fig, ax = plt.subplots(figsize=(10, 3))
ax.plot(n, h_formula, 'r-', linewidth=2, alpha=0.6, label='Closed-form (partial fractions)')
ax.stem(n, h_numerical, linefmt='k-', markerfmt='ko', basefmt='k-', label='lfilter (numerical)')
ax.set_xlabel('n')
ax.set_ylabel('h[n]')
ax.legend(fontsize=8)
ax.grid(True, alpha=0.3)
fig.tight_layout()
plt.show()
Residues: [-3.33333333  3.33333333]
Poles:    [0.5 0.8]
Direct:   []
Figure 5: Partial fraction decomposition verified: closed-form vs numerical impulse response.

A system has the transfer function \(H(z) = \frac{3z}{z - 0.5}\). What is its impulse response \(h[n]\)?

From the table, \(\frac{z}{z-a} \leftrightarrow a^n u[n]\). So:

\[h[n] = 3 \cdot (0.5)^n\,u[n]\]

The impulse response starts at \(h[0] = 3\) and decays by half each sample.


Stability

A filter is only useful if it’s BIBO stable: bounded input always produces bounded output. From Chapter 2, the condition is that the impulse response must be absolutely summable:

\[\sum_{n=-\infty}^{\infty} |h[n]| < \infty\]

The z-transform gives us a simple graphical test (Oppenheim and Schafer 2010): a causal LTI system is BIBO stable if and only if all poles lie strictly inside the unit circle (\(|p_i| < 1\) for all \(i\)).

This makes intuitive sense. Each pole at \(z = a\) contributes a term \(a^n\) to the impulse response. If \(|a| < 1\), the term decays; if \(|a| > 1\), it grows without bound.

fig, axes = plt.subplots(1, 3, figsize=(12, 4))
theta = np.linspace(0, 2 * np.pi, 200)

cases = [
    (0.7, 'Stable (pole at 0.7)', True),
    (1.0, 'Marginally stable (pole at 1.0)', None),
    (1.2, 'Unstable (pole at 1.2)', False),
]

for ax, (pole, title, stable) in zip(axes, cases):
    # Unit circle
    ax.plot(np.cos(theta), np.sin(theta), 'k-', linewidth=1)

    # Shade regions
    if stable is True:
        circle = plt.Circle((0, 0), 1, color='green', alpha=0.1)
        ax.add_patch(circle)
    elif stable is False:
        ax.text(0.5, -1.5, 'UNSTABLE', color='red', fontsize=10,
                fontweight='bold', ha='center', alpha=0.6)

    # Pole
    ax.plot(pole, 0, 'rx', markersize=12, markeredgewidth=2.5)

    # Impulse response inset
    n = np.arange(20)
    h = pole ** n
    # Small inset axes
    inset = ax.inset_axes([0.55, 0.55, 0.4, 0.35])
    inset.stem(n, h, linefmt='k-', markerfmt='k.', basefmt='k-')
    inset.set_title('h[n]', fontsize=8)
    inset.tick_params(labelsize=6)
    if not stable:
        inset.set_ylim(0, min(h.max(), 50))

    ax.axhline(0, color='k', linewidth=0.5)
    ax.axvline(0, color='k', linewidth=0.5)
    ax.set_xlim(-1.8, 1.8)
    ax.set_ylim(-1.8, 1.8)
    ax.set_aspect('equal')
    ax.set_title(title, fontsize=10)
    ax.grid(True, alpha=0.3)

fig.tight_layout()
plt.show()
Figure 6: Stability regions in the z-plane. Poles inside the unit circle → stable; outside → unstable; on the circle → marginally stable.
Marginal stability

When a pole lies exactly on the unit circle (\(|a| = 1\)), the system is marginally stable: the impulse response neither decays nor grows. A pole at \(z = 1\) produces a constant (integrator); a pole at \(z = -1\) produces an alternating sequence. Marginally stable systems are useful in theory (oscillators, integrators) but fragile in practice: any rounding error can push the pole outside the circle.

Checking stability in Python

examples = {
    'Stable IIR':   ([1, -1], [1, -0.95]),
    'Second-order': ([1, 0, -1], [2, 2, 1]),
    'Unstable':     ([1], [1, -1.05]),
}

for name, (b, a) in examples.items():
    _, poles, _ = tf2zpk(b, a)
    max_pole = np.max(np.abs(poles))
    stable = "stable" if max_pole < 1 else "UNSTABLE" if max_pole > 1 else "marginal"
    print(f"{name:20s}  poles: {poles}  |p|_max = {max_pole:.3f}{stable}")
Stable IIR            poles: [0.95]  |p|_max = 0.950  → stable
Second-order          poles: [-0.5+0.5j -0.5-0.5j]  |p|_max = 0.707  → stable
Unstable              poles: [1.05]  |p|_max = 1.050  → UNSTABLE

A filter has the transfer function:

\[H(z) = \frac{z + 1}{z^2 - 0.5z - 0.5}\]

Is it stable? Find the poles and check.

Factor the denominator: \(z^2 - 0.5z - 0.5 = (z - 1)(z + 0.5)\).

The poles are at \(z = 1\) and \(z = -0.5\).

Since one pole is on the unit circle (\(|z| = 1\)), the system is marginally stable, not BIBO stable in the strict sense. The pole at \(z = 1\) means the filter has an integrator-like mode that can cause the output to drift unboundedly.

z, p, k = tf2zpk([1, 1], [1, -0.5, -0.5])
print(f"Poles: {p}")
print(f"Magnitudes: {np.abs(p)}")
Poles: [ 1.  -0.5]
Magnitudes: [1.  0.5]

Minimum-phase systems

A causal, stable system is minimum-phase if all its zeros also lie inside the unit circle (\(|z_i| < 1\) for all \(i\)). This is a stronger condition than stability alone (which only requires poles inside the circle).

Why does this matter? Given a magnitude response \(|H(e^{j\omega T})|\), there are many different systems that share the same magnitude. They differ only in their phase. Among all these systems, the minimum-phase one has:

  • The least group delay: the output arrives as early as possible.
  • A causal, stable inverse: you can undo the filter by inverting \(H(z)\). Since all zeros are inside the unit circle, \(1/H(z)\) has all its poles inside the unit circle too.
  • Minimum energy delay: the impulse response concentrates its energy as early as possible.

Systems with zeros outside the unit circle are called non-minimum-phase. They have the same magnitude response but more phase lag. You can always factor a non-minimum-phase system into a minimum-phase part times an all-pass part (which has unit magnitude everywhere but adds phase).

from scipy.signal import freqz, group_delay

# Minimum-phase: zero at z = 0.8
b_min = [1, -0.8]
a = [1, -0.5]   # same pole for both

# Non-minimum-phase: zero at z = 1/0.8 = 1.25 (reflected outside unit circle)
# Scale to match DC gain: (1 - 0.8) = 0.2, (1 - 1.25) = -0.25 → multiply by -0.2/0.25 = -0.8
b_nonmin = np.array([1, -1.25]) * (-0.8)

fig, axes = plt.subplots(2, 2, figsize=(10, 6))

# Magnitude response (should be identical up to a constant)
for b, label, color in [(b_min, 'Minimum-phase', 'C0'), (b_nonmin, 'Non-minimum-phase', 'C1')]:
    w, H = freqz(b, a, worN=1024)
    axes[0, 0].plot(w/np.pi, 20*np.log10(np.abs(H)), color=color, linewidth=1.5, label=label)
    axes[0, 1].plot(w/np.pi, np.unwrap(np.angle(H)) * 180/np.pi, color=color, linewidth=1.5, label=label)

axes[0, 0].set_ylabel('Magnitude [dB]')
axes[0, 0].set_title('Magnitude response')
axes[0, 0].legend(fontsize=8)
axes[0, 0].grid(True, alpha=0.3)

axes[0, 1].set_ylabel('Phase [degrees]')
axes[0, 1].set_title('Phase response')
axes[0, 1].legend(fontsize=8)
axes[0, 1].grid(True, alpha=0.3)

# Impulse response
n = np.arange(20)
delta = np.zeros(20); delta[0] = 1
for b, label, color in [(b_min, 'Minimum-phase', 'C0'), (b_nonmin, 'Non-minimum-phase', 'C1')]:
    h = lfilter(b, a, delta)
    axes[1, 0].stem(n, h, linefmt=color+'-', markerfmt=color+'o', basefmt='k-', label=label)

axes[1, 0].set_xlabel('n')
axes[1, 0].set_ylabel('h[n]')
axes[1, 0].set_title('Impulse response')
axes[1, 0].legend(fontsize=8)
axes[1, 0].grid(True, alpha=0.3)

# Pole-zero plot
theta = np.linspace(0, 2*np.pi, 200)
axes[1, 1].plot(np.cos(theta), np.sin(theta), 'k-', linewidth=0.8, alpha=0.5)
for b, label, color in [(b_min, 'Min-phase', 'C0'), (b_nonmin, 'Non-min-phase', 'C1')]:
    zeros, poles, _ = tf2zpk(b, a)
    axes[1, 1].plot(zeros.real, zeros.imag, 'o', color=color, markersize=10,
                     markerfacecolor='none', markeredgewidth=2, label=f'{label} zero')
    axes[1, 1].plot(poles.real, poles.imag, 'x', color=color, markersize=10, markeredgewidth=2)
axes[1, 1].axhline(0, color='k', linewidth=0.5)
axes[1, 1].axvline(0, color='k', linewidth=0.5)
axes[1, 1].set_aspect('equal')
axes[1, 1].set_title('Pole-zero plot (○ zeros, × poles)')
axes[1, 1].legend(fontsize=7)
axes[1, 1].grid(True, alpha=0.3)

for ax in axes[1, :]:
    ax.set_xlabel(ax.get_xlabel() or '')
axes[0, 0].set_xlabel('Normalized frequency [×π rad/sample]')
axes[0, 1].set_xlabel('Normalized frequency [×π rad/sample]')
fig.tight_layout()
plt.show()
Figure 7: Minimum-phase vs non-minimum-phase systems with the same magnitude response. The minimum-phase system has less phase lag and concentrates energy earlier.
When does minimum-phase matter?

In filter design, minimum-phase is often the default: scipy.signal.butter, cheby1, etc. produce minimum-phase IIR filters. In signal processing applications like deconvolution or channel equalization, knowing that a system is minimum-phase guarantees that a stable inverse exists. Linear-phase FIR filters are not minimum-phase (their zeros come in reciprocal pairs on the unit circle), but they have other desirable properties, see Chapter 6.


Exploring pole-zero behavior

The z-plane is most powerful as a design tool when you develop intuition for how moving poles and zeros changes the filter. This section builds that intuition through exploration.

Pole radius and impulse response decay

A single real pole at \(z = a\) produces an impulse response \(h[n] = a^n\,u[n]\). As the pole moves closer to the unit circle, the response decays more slowly: the filter “rings” longer and has a narrower bandwidth:

fig, axes = plt.subplots(1, 2, figsize=(10, 4))
n = np.arange(50)

pole_values = [0.5, 0.8, 0.9, 0.95, 0.99]
colors = plt.cm.viridis(np.linspace(0.2, 0.9, len(pole_values)))

for a, c in zip(pole_values, colors):
    h = a ** n
    axes[0].plot(n, h, color=c, linewidth=1.5, label=f'a = {a}')

    # Frequency response: H(z) = z/(z-a)
    w, H = freqz([1], [1, -a], worN=1024)
    axes[1].plot(w/np.pi, 20*np.log10(np.abs(H)), color=c, linewidth=1.5, label=f'a = {a}')

axes[0].set_xlabel('n')
axes[0].set_ylabel('h[n]')
axes[0].set_title('Impulse response')
axes[0].legend(fontsize=7)
axes[0].grid(True, alpha=0.3)

axes[1].set_xlabel('Normalized frequency [×π rad/sample]')
axes[1].set_ylabel('Magnitude [dB]')
axes[1].set_title('Frequency response')
axes[1].set_ylim(-3, 30)
axes[1].legend(fontsize=7)
axes[1].grid(True, alpha=0.3)

fig.tight_layout()
plt.show()
Figure 8: Effect of pole radius on impulse response. Closer to the unit circle = slower decay = narrower bandwidth.

Zero placement and notch filters

Placing a zero on the unit circle at angle \(\theta\) creates a perfect null at the corresponding frequency. This is the principle behind notch filters, used to remove power-line interference (50/60 Hz), for example.

Moving the zero slightly inside the unit circle turns the perfect null into a dip. The closer the zero is to the unit circle, the deeper the notch.

fig, axes = plt.subplots(1, 2, figsize=(10, 4.5))

# Notch at 50 Hz, fs = 500 Hz → ω₀ = 2π·50/500 = 0.2π rad/sample
fs = 500
f_notch = 50
w0 = 2 * np.pi * f_notch / fs

# Zeros at different radii
for r_z, ls, label in [(1.0, '-', 'On unit circle (r=1.0)'),
                         (0.95, '--', 'Inside (r=0.95)'),
                         (0.85, ':', 'Inside (r=0.85)')]:
    # Complex conjugate zero pair at angle ±ω₀
    z1 = r_z * np.exp(1j * w0)
    z2 = r_z * np.exp(-1j * w0)
    b = np.real(np.poly([z1, z2]))

    # Add poles near the zeros for a narrow notch (not a broadband cut)
    p1 = 0.9 * np.exp(1j * w0)
    p2 = 0.9 * np.exp(-1j * w0)
    a = np.real(np.poly([p1, p2]))

    w, H = freqz(b, a, worN=2048, fs=fs)
    axes[0].plot(w, 20*np.log10(np.maximum(np.abs(H), 1e-10)),
                 linestyle=ls, linewidth=1.5, label=label)

axes[0].set_xlabel('Frequency [Hz]')
axes[0].set_ylabel('Magnitude [dB]')
axes[0].set_ylim(-60, 10)
axes[0].set_title(f'Notch at {f_notch} Hz')
axes[0].legend(fontsize=8)
axes[0].grid(True, alpha=0.3)

# Pole-zero plot for the on-unit-circle case
z1 = np.exp(1j * w0)
z2 = np.exp(-1j * w0)
p1 = 0.9 * np.exp(1j * w0)
p2 = 0.9 * np.exp(-1j * w0)

theta = np.linspace(0, 2*np.pi, 200)
axes[1].plot(np.cos(theta), np.sin(theta), 'k-', linewidth=0.8, alpha=0.5)
axes[1].plot([z1.real, z2.real], [z1.imag, z2.imag], 'bo', markersize=10,
             markerfacecolor='none', markeredgewidth=2, label='Zeros')
axes[1].plot([p1.real, p2.real], [p1.imag, p2.imag], 'rx', markersize=10,
             markeredgewidth=2, label='Poles')
axes[1].axhline(0, color='k', linewidth=0.5)
axes[1].axvline(0, color='k', linewidth=0.5)
axes[1].set_aspect('equal')
axes[1].set_title('Pole-zero plot (50 Hz notch)')
axes[1].legend(fontsize=8)
axes[1].grid(True, alpha=0.3)

fig.tight_layout()
plt.show()
Figure 9: Notch filter by zero placement. A zero on the unit circle creates a perfect null; moving it inside reduces the notch depth.

Pole-zero cancellation

When a pole and a zero are close together, they cancel each other’s effect on the frequency response. This is useful for understanding why some filter designs contain near-cancelling pairs, and why removing them barely changes the response.

fig, ax = plt.subplots(figsize=(10, 4))

# Resonator: poles at r·e^{±jω₀}
r = 0.95
w0 = np.pi / 4

# Without cancellation: just the resonator
a_res = np.real(np.poly([r*np.exp(1j*w0), r*np.exp(-1j*w0)]))
w, H1 = freqz([1], a_res, worN=2048)
ax.plot(w/np.pi, 20*np.log10(np.abs(H1)), 'C0-', linewidth=1.5,
        label='Resonator (poles only)')

# With partial cancellation: add zeros at r_z·e^{±jω₀} with r_z < r
for r_z, color, ls in [(0.85, 'C1', '--'), (0.93, 'C2', ':')]:
    b_cancel = np.real(np.poly([r_z*np.exp(1j*w0), r_z*np.exp(-1j*w0)]))
    w, H2 = freqz(b_cancel, a_res, worN=2048)
    ax.plot(w/np.pi, 20*np.log10(np.abs(H2)), color=color, linestyle=ls,
            linewidth=1.5, label=f'+ zeros at r={r_z}')

ax.set_xlabel('Normalized frequency [×π rad/sample]')
ax.set_ylabel('Magnitude [dB]')
ax.set_ylim(-10, 30)
ax.set_title('Pole-zero cancellation reduces resonance peak')
ax.legend(fontsize=8)
ax.grid(True, alpha=0.3)
fig.tight_layout()
plt.show()
Figure 10: Pole-zero cancellation: adding a zero near a pole flattens the resonance peak.
Design by pole-zero placement

These examples illustrate a powerful approach: instead of computing filter coefficients from design algorithms, you can place poles and zeros directly on the z-plane. This works well for low-order, intuitive shaping: notch filters, resonators, and simple equalisers, especially in audio and embedded systems. It does not scale to sharp, high-order frequency-selective filters (steep transition bands, tight ripple specifications), where placing dozens of poles by hand is impractical and the algorithmic methods in Chapter 6 are the right tool.


Putting it all together

Let’s trace a complete example from difference equation to pole-zero analysis. Consider a resonator, a filter that amplifies a narrow frequency band:

\[y[n] = x[n] + 2r\cos(\omega_0)\,y[n-1] - r^2\,y[n-2]\]

where \(r\) controls the resonance sharpness and \(\omega_0\) is the resonant frequency.

fig, axes = plt.subplots(2, 2, figsize=(10, 8))

omega_0 = np.pi / 4   # resonant at π/4 radians/sample

for i, r in enumerate([0.8, 0.95]):
    # Transfer function coefficients
    b = [1]
    a = [1, -2*r*np.cos(omega_0), r**2]

    # Pole-zero plot
    ax_pz = axes[0, i]
    plot_pzmap(b, a, title=f'r = {r}', ax=ax_pz)

    # Impulse response
    ax_ir = axes[1, i]
    n = np.arange(60)
    delta = np.zeros(60)
    delta[0] = 1
    h = lfilter(b, a, delta)
    ax_ir.stem(n, h, linefmt='k-', markerfmt='k.', basefmt='k-')
    ax_ir.set_xlabel('n')
    ax_ir.set_ylabel('h[n]')
    ax_ir.set_title(f'Impulse response (r = {r})')
    ax_ir.grid(True, alpha=0.3)

fig.suptitle(f'Digital resonator at ω₀ = π/4', fontsize=12)
fig.tight_layout()
plt.show()
Figure 11: A digital resonator: transfer function, pole-zero plot, and impulse response. Closer poles to the unit circle produce sharper resonance and longer ringing.

When the poles are closer to the unit circle (\(r = 0.95\)), the impulse response rings longer and the resonance is sharper. If \(r \geq 1\), the poles cross the unit circle and the system becomes unstable: the ringing grows instead of decaying. This is the z-plane telling you directly about the filter’s behaviour.


Summary

Concept Key result
Z-transform \(X(z) = \sum_{n=0}^{\infty} x[n]\,z^{-n}\)
Delay property \(x[n-k] \leftrightarrow z^{-k}\,X(z)\)
Convolution → multiplication \(y = x * h \leftrightarrow Y(z) = X(z)\,H(z)\)
Transfer function \(H(z) = Y(z)/X(z) = \mathcal{Z}\{h[n]\}\)
Poles and zeros Roots of denominator and numerator of \(H(z)\)
BIBO stability All poles inside the unit circle: \(|p_i| < 1\)

The z-transform converts time-domain difference equations into algebraic transfer functions. Poles and zeros on the z-plane give immediate visual insight into stability and filter behaviour. In the next chapter, we evaluate \(H(z)\) on the unit circle to obtain the frequency response: The frequency domain.

Further reading
  • Oppenheim & Schafer, Discrete-Time Signal Processing (2010), Ch. 3: The z-transform
  • Mitra, Digital Signal Processing (2006), Ch. 5.4–5.6: Partial fractions, inverse z-transform

References

Mitra, Sanjit K. 2006. Digital Signal Processing: A Computer-Based Approach. 3rd ed. McGraw-Hill.
Oppenheim, Alan V., and Ronald W. Schafer. 2010. Discrete-Time Signal Processing. 3rd ed. Pearson.
Proakis, John G., and Dimitris G. Manolakis. 2007. Digital Signal Processing: Principles, Algorithms, and Applications. 4th ed. Pearson.