Skip to content

Commit ae4e386

Browse files
larsonerlucascolleyDietBru
authored
ENH: Add half=True kwarg to minimum_phase (scipy#19706)
* ENH: Add half=True kwarg to minimum_phase * FIX: dots * Apply suggestions from code review Co-authored-by: Lucas Colley <[email protected]> * STY: Lint * Type hints and improved example in docstring. * Type hints (especially for `method='homomorphic'`) are a small quality of life improvement for IDE users. * The example now shows a phase plot to illustrate the minimum phase property. * Added a bit of description of the plots to make them more comprehensible to non-experts. * FIX: No unicode * FIX: Optional --------- Co-authored-by: Lucas Colley <[email protected]> Co-authored-by: Dietrich Brunn <[email protected]>
1 parent 13cb1b2 commit ae4e386

File tree

3 files changed

+95
-47
lines changed

3 files changed

+95
-47
lines changed

.circleci/config.yml

+7-1
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,13 @@ jobs:
116116
no_output_timeout: 25m
117117
command: |
118118
export PYTHONPATH=$PWD/build-install/lib/python3.11/site-packages
119-
python dev.py --no-build doc
119+
python dev.py --no-build doc 2>&1 | tee sphinx_log.txt
120+
121+
- run:
122+
name: Check sphinx log for warnings (which are treated as errors)
123+
when: always
124+
command: |
125+
! grep "^.* WARNING: .*$" sphinx_log.txt
120126
121127
- store_artifacts:
122128
path: doc/build/html

scipy/signal/_fir_filter_design.py

+80-43
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from math import ceil, log
44
import operator
55
import warnings
6+
from typing import Literal, Optional
67

78
import numpy as np
89
from numpy.fft import irfft, fft, ifft
@@ -1022,8 +1023,10 @@ def firls(numtaps, bands, desired, *, weight=None, nyq=_NoValue, fs=None):
10221023
# check remaining params
10231024
desired = np.asarray(desired).flatten()
10241025
if bands.size != desired.size:
1025-
raise ValueError("desired must have one entry per frequency, got {} "
1026-
"gains for {} frequencies.".format(desired.size, bands.size))
1026+
raise ValueError(
1027+
f"desired must have one entry per frequency, got {desired.size}"
1028+
f"gains for {bands.size} frequencies."
1029+
)
10271030
desired.shape = (-1, 2)
10281031
if (np.diff(bands) <= 0).any() or (np.diff(bands[:, 0]) < 0).any():
10291032
raise ValueError("bands must be monotonically nondecreasing and have "
@@ -1125,21 +1128,26 @@ def _dhtm(mag):
11251128
return recon
11261129

11271130

1128-
def minimum_phase(h, method='homomorphic', n_fft=None):
1131+
def minimum_phase(h: np.ndarray,
1132+
method: Literal['homomorphic', 'hilbert'] = 'homomorphic',
1133+
n_fft: Optional[int] = None, *, half: bool = True) -> np.ndarray:
11291134
"""Convert a linear-phase FIR filter to minimum phase
11301135
11311136
Parameters
11321137
----------
11331138
h : array
11341139
Linear-phase FIR filter coefficients.
11351140
method : {'hilbert', 'homomorphic'}
1136-
The method to use:
1141+
The provided methods are:
11371142
11381143
'homomorphic' (default)
11391144
This method [4]_ [5]_ works best with filters with an
11401145
odd number of taps, and the resulting minimum phase filter
11411146
will have a magnitude response that approximates the square
1142-
root of the original filter's magnitude response.
1147+
root of the original filter's magnitude response using half
1148+
the number of taps when ``half=True`` (default), or the
1149+
original magnitude spectrum using the same number of taps
1150+
when ``half=False``.
11431151
11441152
'hilbert'
11451153
This method [1]_ is designed to be used with equiripple
@@ -1149,12 +1157,20 @@ def minimum_phase(h, method='homomorphic', n_fft=None):
11491157
n_fft : int
11501158
The number of points to use for the FFT. Should be at least a
11511159
few times larger than the signal length (see Notes).
1160+
half : bool
1161+
If ``True``, create a filter that is half the length of the original, with a
1162+
magnitude spectrum that is the square root of the original. If ``False``,
1163+
create a filter that is the same length as the original, with a magnitude
1164+
spectrum that is designed to match the original (only supported when
1165+
``method='homomorphic'``).
1166+
1167+
.. versionadded:: 1.14.0
11521168
11531169
Returns
11541170
-------
11551171
h_minimum : array
11561172
The minimum-phase version of the filter, with length
1157-
``(length(h) + 1) // 2``.
1173+
``(len(h) + 1) // 2`` when ``half is True`` or ``len(h)`` otherwise.
11581174
11591175
See Also
11601176
--------
@@ -1185,9 +1201,8 @@ def minimum_phase(h, method='homomorphic', n_fft=None):
11851201
11861202
Alternative implementations exist for creating minimum-phase filters,
11871203
including zero inversion [2]_ and spectral factorization [3]_ [4]_.
1188-
For more information, see:
1189-
1190-
http://dspguru.com/dsp/howtos/how-to-design-minimum-phase-fir-filters
1204+
For more information, see `this DSPGuru page
1205+
<http://dspguru.com/dsp/howtos/how-to-design-minimum-phase-fir-filters>`__.
11911206
11921207
References
11931208
----------
@@ -1205,49 +1220,66 @@ def minimum_phase(h, method='homomorphic', n_fft=None):
12051220
.. [4] J. S. Lim, Advanced Topics in Signal Processing.
12061221
Englewood Cliffs, N.J.: Prentice Hall, 1988.
12071222
.. [5] A. V. Oppenheim, R. W. Schafer, and J. R. Buck,
1208-
"Discrete-Time Signal Processing," 2nd edition.
1209-
Upper Saddle River, N.J.: Prentice Hall, 1999.
1223+
"Discrete-Time Signal Processing," 3rd edition.
1224+
Upper Saddle River, N.J.: Pearson, 2009.
12101225
12111226
Examples
12121227
--------
1213-
Create an optimal linear-phase filter, then convert it to minimum phase:
1228+
Create an optimal linear-phase low-pass filter `h` with a transition band of
1229+
[0.2, 0.3] (assuming a Nyquist frequency of 1):
12141230
12151231
>>> import numpy as np
12161232
>>> from scipy.signal import remez, minimum_phase, freqz, group_delay
12171233
>>> import matplotlib.pyplot as plt
12181234
>>> freq = [0, 0.2, 0.3, 1.0]
12191235
>>> desired = [1, 0]
1220-
>>> h_linear = remez(151, freq, desired, fs=2.)
1236+
>>> h_linear = remez(151, freq, desired, fs=2)
12211237
12221238
Convert it to minimum phase:
12231239
1224-
>>> h_min_hom = minimum_phase(h_linear, method='homomorphic')
1225-
>>> h_min_hil = minimum_phase(h_linear, method='hilbert')
1226-
1227-
Compare the three filters:
1228-
1229-
>>> fig, axs = plt.subplots(4, figsize=(4, 8))
1230-
>>> for h, style, color in zip((h_linear, h_min_hom, h_min_hil),
1231-
... ('-', '-', '--'), ('k', 'r', 'c')):
1232-
... w, H = freqz(h)
1233-
... w, gd = group_delay((h, 1))
1234-
... w /= np.pi
1235-
... axs[0].plot(h, color=color, linestyle=style)
1236-
... axs[1].plot(w, np.abs(H), color=color, linestyle=style)
1237-
... axs[2].plot(w, 20 * np.log10(np.abs(H)), color=color, linestyle=style)
1238-
... axs[3].plot(w, gd, color=color, linestyle=style)
1239-
>>> for ax in axs:
1240-
... ax.grid(True, color='0.5')
1241-
... ax.fill_between(freq[1:3], *ax.get_ylim(), color='#ffeeaa', zorder=1)
1242-
>>> axs[0].set(xlim=[0, len(h_linear) - 1], ylabel='Amplitude', xlabel='Samples')
1243-
>>> axs[1].legend(['Linear', 'Min-Hom', 'Min-Hil'], title='Phase')
1244-
>>> for ax, ylim in zip(axs[1:], ([0, 1.1], [-150, 10], [-60, 60])):
1245-
... ax.set(xlim=[0, 1], ylim=ylim, xlabel='Frequency')
1246-
>>> axs[1].set(ylabel='Magnitude')
1247-
>>> axs[2].set(ylabel='Magnitude (dB)')
1248-
>>> axs[3].set(ylabel='Group delay')
1249-
>>> plt.tight_layout()
1240+
>>> h_hil = minimum_phase(h_linear, method='hilbert')
1241+
>>> h_hom = minimum_phase(h_linear, method='homomorphic')
1242+
>>> h_hom_full = minimum_phase(h_linear, method='homomorphic', half=False)
1243+
1244+
Compare the impulse and frequency response of the four filters:
1245+
1246+
>>> fig0, ax0 = plt.subplots(figsize=(6, 3), tight_layout=True)
1247+
>>> fig1, axs = plt.subplots(3, sharex='all', figsize=(6, 6), tight_layout=True)
1248+
>>> ax0.set_title("Impulse response")
1249+
>>> ax0.set(xlabel='Samples', ylabel='Amplitude', xlim=(0, len(h_linear) - 1))
1250+
>>> axs[0].set_title("Frequency Response")
1251+
>>> axs[0].set(xlim=(0, .65), ylabel="Magnitude / dB")
1252+
>>> axs[1].set(ylabel="Phase / rad")
1253+
>>> axs[2].set(ylabel="Group Delay / samples", ylim=(-31, 81),
1254+
... xlabel='Normalized Frequency (Nyqist frequency: 1)')
1255+
>>> for h, lb in ((h_linear, f'Linear ({len(h_linear)})'),
1256+
... (h_hil, f'Min-Hilbert ({len(h_hil)})'),
1257+
... (h_hom, f'Min-Homomorphic ({len(h_hom)})'),
1258+
... (h_hom_full, f'Min-Homom. Full ({len(h_hom_full)})')):
1259+
... w_H, H = freqz(h, fs=2)
1260+
... w_gd, gd = group_delay((h, 1), fs=2)
1261+
...
1262+
... alpha = 1.0 if lb == 'linear' else 0.5 # full opacity for 'linear' line
1263+
... ax0.plot(h, '.-', alpha=alpha, label=lb)
1264+
... axs[0].plot(w_H, 20 * np.log10(np.abs(H)), alpha=alpha)
1265+
... axs[1].plot(w_H, np.unwrap(np.angle(H)), alpha=alpha, label=lb)
1266+
... axs[2].plot(w_gd, gd, alpha=alpha)
1267+
>>> ax0.grid(True)
1268+
>>> ax0.legend(title='Filter Phase (Order)')
1269+
>>> axs[1].legend(title='Filter Phase (Order)', loc='lower right')
1270+
>>> for ax_ in axs: # shade transition band:
1271+
... ax_.axvspan(freq[1], freq[2], color='y', alpha=.25)
1272+
... ax_.grid(True)
1273+
>>> plt.show()
12501274
1275+
The impulse response and group delay plot depict the 75 sample delay of the linear
1276+
phase filter `h`. The phase should also be linear in the stop band--due to the small
1277+
magnitude, numeric noise dominates there. Furthermore, the plots show that the
1278+
minimum phase filters clearly show a reduced (negative) phase slope in the pass and
1279+
transition band. The plots also illustrate that the filter with parameters
1280+
``method='homomorphic', half=False`` has same order and magnitude response as the
1281+
linear filter `h` wheras the other minimum phase filters have only half the order
1282+
and the square root of the magnitude response.
12511283
"""
12521284
h = np.asarray(h)
12531285
if np.iscomplexobj(h):
@@ -1261,6 +1293,8 @@ def minimum_phase(h, method='homomorphic', n_fft=None):
12611293
if not isinstance(method, str) or method not in \
12621294
('homomorphic', 'hilbert',):
12631295
raise ValueError(f'method must be "homomorphic" or "hilbert", got {method!r}')
1296+
if method == "hilbert" and not half:
1297+
raise ValueError("`half=False` is only supported when `method='homomorphic'`")
12641298
if n_fft is None:
12651299
n_fft = 2 ** int(np.ceil(np.log2(2 * (len(h) - 1) / 0.01)))
12661300
n_fft = int(n_fft)
@@ -1283,19 +1317,22 @@ def minimum_phase(h, method='homomorphic', n_fft=None):
12831317
# take 0.25*log(|H|**2) = 0.5*log(|H|)
12841318
h_temp += 1e-7 * h_temp[h_temp > 0].min() # don't let log blow up
12851319
np.log(h_temp, out=h_temp)
1286-
h_temp *= 0.5
1320+
if half: # halving of magnitude spectrum optional
1321+
h_temp *= 0.5
12871322
# IDFT
12881323
h_temp = ifft(h_temp).real
12891324
# multiply pointwise by the homomorphic filter
12901325
# lmin[n] = 2u[n] - d[n]
1326+
# i.e., double the positive frequencies and zero out the negative ones;
1327+
# Oppenheim+Shafer 3rd ed p991 eq13.42b and p1004 fig13.7
12911328
win = np.zeros(n_fft)
12921329
win[0] = 1
1293-
stop = (len(h) + 1) // 2
1330+
stop = n_fft // 2
12941331
win[1:stop] = 2
1295-
if len(h) % 2:
1332+
if n_fft % 2:
12961333
win[stop] = 1
12971334
h_temp *= win
12981335
h_temp = ifft(np.exp(fft(h_temp)))
12991336
h_minimum = h_temp.real
1300-
n_out = n_half + len(h) % 2
1337+
n_out = (n_half + len(h) % 2) if half else len(h)
13011338
return h_minimum[:n_out]

scipy/signal/tests/test_fir_filter_design.py

+8-3
Original file line numberDiff line numberDiff line change
@@ -655,6 +655,8 @@ def test_bad_args(self):
655655
assert_raises(ValueError, minimum_phase, np.ones(10), n_fft=8)
656656
assert_raises(ValueError, minimum_phase, np.ones(10), method='foo')
657657
assert_warns(RuntimeWarning, minimum_phase, np.arange(3))
658+
with pytest.raises(ValueError, match="is only supported when"):
659+
minimum_phase(np.ones(3), method='hilbert', half=False)
658660

659661
def test_homomorphic(self):
660662
# check that it can recover frequency responses of arbitrary
@@ -669,9 +671,12 @@ def test_homomorphic(self):
669671
rng = np.random.RandomState(0)
670672
for n in (2, 3, 10, 11, 15, 16, 17, 20, 21, 100, 101):
671673
h = rng.randn(n)
672-
h_new = minimum_phase(np.convolve(h, h[::-1]))
673-
assert_allclose(np.abs(fft(h_new)),
674-
np.abs(fft(h)), rtol=1e-4)
674+
h_linear = np.convolve(h, h[::-1])
675+
h_new = minimum_phase(h_linear)
676+
assert_allclose(np.abs(fft(h_new)), np.abs(fft(h)), rtol=1e-4)
677+
h_new = minimum_phase(h_linear, half=False)
678+
assert len(h_linear) == len(h_new)
679+
assert_allclose(np.abs(fft(h_new)), np.abs(fft(h_linear)), rtol=1e-4)
675680

676681
def test_hilbert(self):
677682
# compare to MATLAB output of reference implementation

0 commit comments

Comments
 (0)