Skip to content

Commit 83e59a9

Browse files
roryyorkeDietBru
andauthored
BUG: signal.bilinear handles complex input, and strips leading zeros (scipy#22313)
* Add test for signal.bilinear for complex input * BUG: signal.bilinear handles complex input Fixes scipygh-13823. * Add leading-zeros test for signal.bilinear * BUG: trim leading zeros in inputs in signal.bilinear * Rework of `signal.blinear` function. * Simplified algorithm by utilizing NumPy's Polynomial class. * Improved docstr and example. * Added unit test to check for exceptions. * Improve docstr of `signal.blinear` function by making the formulas a narrower by using subscript array notation. Also, some typos were removed. * Reduce the width of the longest equation in the docstr of `signal.blinear` a little more. * Remove typos in function signal.bilinear Co-authored-by: Rory Yorke <rory.yorke@gmail.com> * Correct two typos in docst of `signal.bilinear`. --------- Co-authored-by: Dietrich Brunn <Dietrich.Brunn@web.de>
1 parent e44d135 commit 83e59a9

File tree

2 files changed

+183
-66
lines changed

2 files changed

+183
-66
lines changed

scipy/signal/_filter_design.py

Lines changed: 112 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -2150,88 +2150,141 @@ def lp2bs(b, a, wo=1.0, bw=1.0):
21502150

21512151

21522152
def bilinear(b, a, fs=1.0):
2153-
r"""
2154-
Return a digital IIR filter from an analog one using a bilinear transform.
2155-
2156-
Transform a set of poles and zeros from the analog s-plane to the digital
2157-
z-plane using Tustin's method, which substitutes ``2*fs*(z-1) / (z+1)`` for
2158-
``s``, maintaining the shape of the frequency response.
2153+
r"""Calculate a digital IIR filter from an analog transfer function by utilizing
2154+
the bilinear transform.
21592155
21602156
Parameters
21612157
----------
21622158
b : array_like
2163-
Numerator of the analog filter transfer function.
2159+
Coefficients of the numerator polynomial of the analog transfer function in
2160+
form of a complex- or real-valued 1d array.
21642161
a : array_like
2165-
Denominator of the analog filter transfer function.
2162+
Coefficients of the denominator polynomial of the analog transfer function in
2163+
form of a complex- or real-valued 1d array.
21662164
fs : float
2167-
Sample rate, as ordinary frequency (e.g., hertz). No prewarping is
2165+
Sample rate, as ordinary frequency (e.g., hertz). No pre-warping is
21682166
done in this function.
21692167
21702168
Returns
21712169
-------
2172-
b : ndarray
2173-
Numerator of the transformed digital filter transfer function.
2174-
a : ndarray
2175-
Denominator of the transformed digital filter transfer function.
2170+
beta : ndarray
2171+
Coefficients of the numerator polynomial of the digital transfer function in
2172+
form of a complex- or real-valued 1d array.
2173+
alpha : ndarray
2174+
Coefficients of the denominator polynomial of the digital transfer function in
2175+
form of a complex- or real-valued 1d array.
2176+
2177+
Notes
2178+
-----
2179+
The parameters :math:`b = [b_0, \ldots, b_Q]` and :math:`a = [a_0, \ldots, a_P]`
2180+
are 1d arrays of length :math:`Q+1` and :math:`P+1`. They define the analog
2181+
transfer function
2182+
2183+
.. math::
2184+
2185+
H_a(s) = \frac{b_0 s^Q + b_1 s^{Q-1} + \cdots + b_Q}{
2186+
a_0 s^P + a_1 s^{P-1} + \cdots + a_P}\ .
2187+
2188+
The bilinear transform [1]_ is applied by substituting
2189+
2190+
.. math::
2191+
2192+
s = \kappa \frac{z-1}{z+1}\ , \qquad \kappa := 2 f_s\ ,
2193+
2194+
into :math:`H_a(s)`, with :math:`f_s` being the sampling rate.
2195+
This results in the digital transfer function in the :math:`z`-domain
2196+
2197+
.. math::
2198+
2199+
H_d(z) = \frac{b_0 \left(\kappa \frac{z-1}{z+1}\right)^Q +
2200+
b_1 \left(\kappa \frac{z-1}{z+1}\right)^{Q-1} +
2201+
\cdots + b_Q}{
2202+
a_0 \left(\kappa \frac{z-1}{z+1}\right)^P +
2203+
a_1 \left(\kappa \frac{z-1}{z+1}\right)^{P-1} +
2204+
\cdots + a_P}\ .
2205+
2206+
This expression can be simplified by multiplying numerator and denominator by
2207+
:math:`(z+1)^N`, with :math:`N=\max(P, Q)`. This allows :math:`H_d(z)` to be
2208+
reformulated as
2209+
2210+
.. math::
2211+
2212+
& & \frac{b_0 \big(\kappa (z-1)\big)^Q (z+1)^{N-Q} +
2213+
b_1 \big(\kappa (z-1)\big)^{Q-1} (z+1)^{N-Q+1} +
2214+
\cdots + b_Q(z+1)^N}{
2215+
a_0 \big(\kappa (z-1)\big)^P (z+1)^{N-P} +
2216+
a_1 \big(\kappa (z-1)\big)^{P-1} (z+1)^{N-P+1} +
2217+
\cdots + a_P(z+1)^N}\\
2218+
&=:& \frac{\beta_0 + \beta_1 z^{-1} + \cdots + \beta_N z^{-N}}{
2219+
\alpha_0 + \alpha_1 z^{-1} + \cdots + \alpha_N z^{-N}}\ .
2220+
2221+
2222+
This is the equation implemented to perform the bilinear transform. Note that for
2223+
large :math:`f_s`, :math:`\kappa^Q` or :math:`\kappa^P` can cause a numeric
2224+
overflow for sufficiently large :math:`P` or :math:`Q`.
2225+
2226+
References
2227+
----------
2228+
.. [1] "Bilinear Transform", Wikipedia,
2229+
https://en.wikipedia.org/wiki/Bilinear_transform
21762230
21772231
See Also
21782232
--------
2179-
lp2lp, lp2hp, lp2bp, lp2bs
2180-
bilinear_zpk
2233+
lp2lp, lp2hp, lp2bp, lp2bs, bilinear_zpk
21812234
21822235
Examples
21832236
--------
2237+
The following example shows the frequency response of an analog bandpass filter and
2238+
the corresponding digital filter derived by utilitzing the bilinear transform:
2239+
21842240
>>> from scipy import signal
21852241
>>> import matplotlib.pyplot as plt
21862242
>>> import numpy as np
2243+
...
2244+
>>> fs = 100 # sampling frequency
2245+
>>> om_c = 2 * np.pi * np.array([7, 13]) # corner frequencies
2246+
>>> bb_s, aa_s = signal.butter(4, om_c, btype='bandpass', analog=True, output='ba')
2247+
>>> bb_z, aa_z = signal.bilinear(bb_s, aa_s, fs)
2248+
...
2249+
>>> w_z, H_z = signal.freqz(bb_z, aa_z) # frequency response of digitial filter
2250+
>>> w_s, H_s = signal.freqs(bb_s, aa_s, worN=w_z*fs) # analog filter response
2251+
...
2252+
>>> f_z, f_s = w_z * fs / (2*np.pi), w_s / (2*np.pi)
2253+
>>> Hz_dB, Hs_dB = (20*np.log10(np.abs(H_).clip(1e-10)) for H_ in (H_z, H_s))
2254+
>>> fg0, ax0 = plt.subplots()
2255+
>>> ax0.set_title("Frequency Response of 4-th order Bandpass Filter")
2256+
>>> ax0.set(xlabel='Frequency $f$ in Hertz', ylabel='Magnitude in dB',
2257+
... xlim=[f_z[1], fs/2], ylim=[-200, 2])
2258+
>>> ax0.semilogx(f_z, Hz_dB, alpha=.5, label=r'$|H_z(e^{j 2 \pi f})|$')
2259+
>>> ax0.semilogx(f_s, Hs_dB, alpha=.5, label=r'$|H_s(j 2 \pi f)|$')
2260+
>>> ax0.legend()
2261+
>>> ax0.grid(which='both', axis='x')
2262+
>>> ax0.grid(which='major', axis='y')
2263+
>>> plt.show()
21872264
2188-
>>> fs = 100
2189-
>>> bf = 2 * np.pi * np.array([7, 13])
2190-
>>> filts = signal.lti(*signal.butter(4, bf, btype='bandpass',
2191-
... analog=True))
2192-
>>> filtz = signal.lti(*signal.bilinear(filts.num, filts.den, fs))
2193-
>>> wz, hz = signal.freqz(filtz.num, filtz.den)
2194-
>>> ws, hs = signal.freqs(filts.num, filts.den, worN=fs*wz)
2195-
2196-
>>> plt.semilogx(wz*fs/(2*np.pi), 20*np.log10(np.abs(hz).clip(1e-15)),
2197-
... label=r'$|H_z(e^{j \omega})|$')
2198-
>>> plt.semilogx(wz*fs/(2*np.pi), 20*np.log10(np.abs(hs).clip(1e-15)),
2199-
... label=r'$|H(j \omega)|$')
2200-
>>> plt.legend()
2201-
>>> plt.xlabel('Frequency [Hz]')
2202-
>>> plt.ylabel('Amplitude [dB]')
2203-
>>> plt.grid(True)
2265+
The difference in the higher frequencies shown in the plot is caused by an effect
2266+
called "frequency warping". [1]_ describes a method called "pre-warping" to
2267+
reduce those deviations.
22042268
"""
2269+
b, a = np.atleast_1d(b), np.atleast_1d(a) # convert scalars, if needed
2270+
if not a.ndim == 1:
2271+
raise ValueError(f"Parameter a is not a 1d array since {a.shape=}")
2272+
if not b.ndim == 1:
2273+
raise ValueError(f"Parameter b is not a 1d array since {b.shape=}")
2274+
b, a = np.trim_zeros(b, 'f'), np.trim_zeros(a, 'f') # remove leading zeros
22052275
fs = _validate_fs(fs, allow_none=False)
2206-
a, b = map(atleast_1d, (a, b))
2207-
D = len(a) - 1
2208-
N = len(b) - 1
2209-
artype = float
2210-
M = max([N, D])
2211-
Np = M
2212-
Dp = M
2213-
bprime = np.empty(Np + 1, artype)
2214-
aprime = np.empty(Dp + 1, artype)
2215-
for j in range(Np + 1):
2216-
val = 0.0
2217-
for i in range(N + 1):
2218-
for k in range(i + 1):
2219-
for l in range(M - i + 1):
2220-
if k + l == j:
2221-
val += (comb(i, k) * comb(M - i, l) * b[N - i] *
2222-
pow(2 * fs, i) * (-1) ** k)
2223-
bprime[j] = real(val)
2224-
for j in range(Dp + 1):
2225-
val = 0.0
2226-
for i in range(D + 1):
2227-
for k in range(i + 1):
2228-
for l in range(M - i + 1):
2229-
if k + l == j:
2230-
val += (comb(i, k) * comb(M - i, l) * a[D - i] *
2231-
pow(2 * fs, i) * (-1) ** k)
2232-
aprime[j] = real(val)
22332276

2234-
return normalize(bprime, aprime)
2277+
# Splitting the factor fs*2 between numerator and denominator reduces the chance of
2278+
# numeric overflow for large fs and large N:
2279+
fac = np.sqrt(fs*2)
2280+
zp1 = np.polynomial.Polynomial((+1, 1)) / fac # Polynomial (z + 1) / fac
2281+
zm1 = np.polynomial.Polynomial((-1, 1)) * fac # Polynomial (z - 1) * fac
2282+
# Note that NumPy's Polynomial coefficient order is backward compared to a and b.
2283+
2284+
N = max(len(a), len(b)) - 1
2285+
numerator = sum(b_ * zp1**(N-q) * zm1**q for q, b_ in enumerate(b[::-1]))
2286+
denominator = sum(a_ * zp1**(N-p) * zm1**p for p, a_ in enumerate(a[::-1]))
2287+
return normalize(numerator.coef[::-1], denominator.coef[::-1])
22352288

22362289

22372290
def _validate_gpass_gstop(gpass, gstop):

scipy/signal/tests/test_filter_design.py

Lines changed: 71 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import warnings
22

3+
from itertools import product
4+
35
from scipy._lib import _pep440
46
import numpy as np
57
from numpy.testing import (
@@ -1389,22 +1391,84 @@ def test_basic(self):
13891391

13901392

13911393
class TestBilinear:
1394+
"""Tests for function `signal.bilinear`. """
1395+
1396+
def test_exceptions(self):
1397+
"""Raise all exceptions in `bilinear()`. """
1398+
with pytest.raises(ValueError, match="Parameter a is not .*"):
1399+
bilinear(1., np.array([[1, 2, 3]]))
1400+
with pytest.raises(ValueError, match="Parameter b is not .*"):
1401+
bilinear(np.ones((2,3)), 1. )
13921402

13931403
def test_basic(self):
1404+
# reference output values computed with sympy
13941405
b = [0.14879732743343033]
13951406
a = [1, 0.54552236880522209, 0.14879732743343033]
1407+
b_zref = [0.08782128175913713, 0.17564256351827426, 0.08782128175913713]
1408+
a_zref = [1.0, -1.0047722097030667, 0.3560573367396151]
1409+
13961410
b_z, a_z = bilinear(b, a, 0.5)
1397-
assert_array_almost_equal(b_z, [0.087821, 0.17564, 0.087821],
1398-
decimal=5)
1399-
assert_array_almost_equal(a_z, [1, -1.0048, 0.35606], decimal=4)
1411+
1412+
assert_array_almost_equal_nulp(b_z, b_zref)
1413+
assert_array_almost_equal_nulp(a_z, a_zref)
14001414

14011415
b = [1, 0, 0.17407467530697837]
14021416
a = [1, 0.18460575326152251, 0.17407467530697837]
1417+
b_zref = [0.8641286432189045, -1.2157757001964216, 0.8641286432189045]
1418+
a_zref = [1.0, -1.2157757001964216, 0.7282572864378091]
1419+
14031420
b_z, a_z = bilinear(b, a, 0.5)
1404-
assert_array_almost_equal(b_z, [0.86413, -1.2158, 0.86413],
1405-
decimal=4)
1406-
assert_array_almost_equal(a_z, [1, -1.2158, 0.72826],
1407-
decimal=4)
1421+
1422+
assert_array_almost_equal_nulp(b_z, b_zref)
1423+
assert_array_almost_equal_nulp(a_z, a_zref)
1424+
1425+
1426+
def test_ignore_leading_zeros(self):
1427+
# regression for gh-6606
1428+
# results shouldn't change when leading zeros are added to
1429+
# input numerator or denominator
1430+
b = [0.14879732743343033]
1431+
a = [1, 0.54552236880522209, 0.14879732743343033]
1432+
1433+
b_zref = [0.08782128175913713, 0.17564256351827426, 0.08782128175913713]
1434+
a_zref = [1.0, -1.0047722097030667, 0.3560573367396151]
1435+
1436+
for lzn, lzd in product(range(4), range(4)):
1437+
b_z, a_z = bilinear(np.pad(b, (lzn, 0)),
1438+
np.pad(a, (lzd, 0)),
1439+
0.5)
1440+
assert_array_almost_equal_nulp(b_z, b_zref)
1441+
assert_array_almost_equal_nulp(a_z, a_zref)
1442+
1443+
1444+
def test_complex(self):
1445+
# reference output values computed with sympy
1446+
# this is an elliptical filter, 5Hz width, centered at +50Hz:
1447+
# z, p, k = signal.ellip(2, 0.5, 20, 2*np.pi*5/2, output='zpk', analog=True)
1448+
# z = z.astype(complex) + 2j * np.pi * 50
1449+
# p = p.astype(complex) + 2j * np.pi * 50
1450+
# b, a = signal.zpk2tf(z, p, k)
1451+
b = [(0.09999999999999991+0j),
1452+
-62.831853071795805j,
1453+
(-9505.857007071314+0j)]
1454+
a = [(1+0j),
1455+
(21.09511000343942-628.3185307179587j),
1456+
(-98310.74322875646-6627.2242613473845j)]
1457+
# sample frequency
1458+
fs = 1000
1459+
b_zref = [(0.09905575106715676-0.00013441423112828688j),
1460+
(-0.18834281923181084-0.06032810039049478j),
1461+
(0.08054306669414343+0.05766172295523972j)]
1462+
a_zref = [(1+0j),
1463+
(-1.8839476369292854-0.606808151331815j),
1464+
(0.7954687330018285+0.5717459398142481j)]
1465+
1466+
b_z, a_z = bilinear(b, a, fs)
1467+
1468+
# the 3 ulp difference determined from testing
1469+
assert_array_almost_equal_nulp(b_z, b_zref, 3)
1470+
assert_array_almost_equal_nulp(a_z, a_zref, 3)
1471+
14081472

14091473
def test_fs_validation(self):
14101474
b = [0.14879732743343033]

0 commit comments

Comments
 (0)