pm_remez/lib.rs
1//! # Parks-McClellan Remez FIR design algorithm
2//!
3//! The [`pm_remez`](crate) crate is a modern implementation of the
4//! Parks-McClellan Remez exchange algorithm. It supports the design of FIR
5//! filters with even symmetry and odd symmetry, and with an even number of taps
6//! and an odd number of taps, by reducing all these cases to the even symmetry
7//! odd number of taps case. The desired frequency response in each band, as
8//! well as the weights, can be defined as arbitrary functions. The crate
9//! supports using
10//! [`num-bigfloat`](https://docs.rs/num-bigfloat/latest/num_bigfloat/), or any
11//! other high precision floating point package that implements the [`Float`]
12//! trait for the calculations. This can be used to solve numerically
13//! challenging problems that are difficult to solve using `f64` arithmetic.
14//!
15//! The implementation draws ideas from \[2\] to make the algorithm robust
16//! against numerical errors. These ideas include the use of Chebyshev proxy
17//! root finding to find the extrema of the weighted error function in the Remez
18//! exchange step.
19//!
20//! ## Examples
21//!
22//! The main function of this crate is [`pm_remez`], which takes a
23//! [`DesignParameters`] object defining the filter to be constructed and
24//! returns a [`PMDesign`] struct containing the filter taps and other
25//! information. There are two coding styles in which this function can be used.
26//!
27//! The first style uses [`BandSetting`] to define each band, setting the
28//! desired response and weight separately on each band. The following
29//! constructs a lowpass filter by setting the desired response to a
30//! [`constant`] of one in the passband and a constant of zero in the
31//! stopband. Other kinds of reponses can be specified with [`linear`] (linear
32//! slope interpolating two values) and [`function`] (arbitrary function). A
33//! weight different from the default of `constant(1.0)` can be provided with
34//! [`BandSetting::with_weight`]. The weight can be defined using `constant`,
35//! `linear` or `function`.
36//!
37//! ```
38//! # #[cfg(any(feature = "lapack-backend", feature = "faer-backend", feature = "nalgebra-backend"))]
39//! # fn main() -> Result<(), pm_remez::error::Error> {
40//! use pm_remez::{
41//! constant, pm_parameters, pm_remez,
42//! BandSetting, PMParameters, ParametersBuilder,
43//! };
44//! let bands = [
45//! BandSetting::new(0.0, 0.2, constant(1.0))?,
46//! BandSetting::new(0.3, 0.5, constant(0.0))?,
47//! ];
48//! let num_taps = 35;
49//! let parameters = pm_parameters(num_taps, &bands)?;
50//! let design = pm_remez(¶meters)?;
51//! # Ok(())
52//! # }
53//! # #[cfg(not(any(feature = "lapack-backend", feature = "faer-backend", feature = "nalgebra-backend")))]
54//! # fn main() {}
55//! ```
56//!
57//! The second style is closer to how the `pm_remez` function is implemented. It
58//! uses the [`PMParameters`] struct to define a list of [`Band`]s and the
59//! desired response and weight as single functions for all the bands. The
60//! `pm_remez` function is generic in the types of the desired response and
61//! weight functions, so this style can enable more compile time optimizations
62//! by using monomorphization.
63//!
64//! This designs the same lowpass filter using this second coding style.
65//!
66//! ```
67//! # #[cfg(any(feature = "lapack-backend", feature = "faer-backend", feature = "nalgebra-backend"))]
68//! # fn main() -> Result<(), pm_remez::error::Error> {
69//! use pm_remez::{pm_remez, Band, BandSetting, PMParameters};
70//! let num_taps = 35;
71//! let parameters = PMParameters::new(
72//! num_taps,
73//! vec![Band::new(0.0, 0.2)?, Band::new(0.3, 0.5)?],
74//! |f| if f < 0.25 { 1.0 } else { 0.0 },
75//! |_| 1.0,
76//! )?;
77//! let design = pm_remez(¶meters)?;
78//! # Ok(())
79//! # }
80//! # #[cfg(not(any(feature = "lapack-backend", feature = "faer-backend", feature = "nalgebra-backend")))]
81//! # fn main() {}
82//! ```
83//!
84//! The [documentation of the Python bindings](https://pm-remez.readthedocs.io/)
85//! contains a series of examples that show how to use pm-remez to design
86//! commonly used types of FIR filters. These examples are also useful to
87//! understand how the Rust `pm_remez` function can be used.
88//!
89//! ## Building
90//!
91//! The `pm_remez` crate supports different backends to solve eigenvalue
92//! problems. These are selected with feature flags. See [`EigenvalueBackend`]
93//! for more details. By default, only the faer backend is enabled, which is a
94//! pure Rust implementation.
95//!
96//! Another supported backend uses `ndarray_linalg` to solve eigenvalue
97//! problems with LAPACK. It is enabled with the `lapack-backend` feature flag.
98//! The `pm_remez` crate has several feature flags that are used to select the
99//! LAPACK backend. Exactly one of these features needs to be enabled to build
100//! `pm_remez` with the `lapack-backend` flag. The feature flags are
101//! `openblas-static`, `openblas-system`, `netlib-static`, `netlib-system`,
102//! `intel-mkl-static` and `intel-mkl-system`. The `-static` versions of each
103//! flag build the LAPACK backend and link statically against it. The `-system`
104//! versions link against a system-installed library (linking can be dynamic or
105//! static depending on which type of library is installed).
106//!
107//! ## References
108//!
109//! \[1\] M. Ahsan and T. Saramaki, "Two Novel Implementations of the Remez
110//! Multiple Exchange Algorithm for Optimum FIR Filter Design", MATLAB - A
111//! Fundamental Tool for Scientific Computing and Engineering Applications -
112//! Volume 2. InTech, Sep. 26, 2012.
113//!
114//! \[2\] S.I. Filip. "A Robust and Scalable Implementation of the
115//! Parks-McClellan Algorithm for Designing FIR Filters," in ACM
116//! Trans. Math. Softw. 43, 1, Article 7, March 2017.
117//!
118//! \[3\] J. McClellan, T. Parks and L. Rabiner, "A computer program for designing
119//! optimum FIR linear phase digital filters," in IEEE Transactions on Audio and
120//! Electroacoustics, vol. 21, no. 6, pp. 506-526, December 1973
121//!
122//! \[4\] T. Parks and J. McClellan, "Chebyshev Approximation for Nonrecursive
123//! Digital Filters with Linear Phase," in IEEE Transactions on Circuit Theory,
124//! vol. 19, no. 2, pp. 189-194, March 1972.
125//!
126//! \[5\] B.N. Parlett and C. Reinsch, "Balancing a matrix for calculation of
127//! eigenvalues and eigenvectors". Numer. Math. 13, 293–304 (1969).
128//!
129
130#![warn(missing_docs)]
131
132use itertools::{Itertools, MinMaxResult};
133use num_traits::{Float, FloatConst};
134
135mod bands;
136use bands::*;
137mod barycentric;
138use barycentric::*;
139mod convf64;
140pub use convf64::Convf64;
141mod chebyshev;
142use chebyshev::compute_cheby_coefficients;
143mod eigenvalues;
144#[cfg(any(
145 feature = "faer-backend",
146 feature = "lapack-backend",
147 feature = "nalgebra-backend"
148))]
149pub use eigenvalues::DefaultEigenvalueBackend;
150pub use eigenvalues::EigenvalueBackend;
151#[cfg(feature = "faer-backend")]
152pub use eigenvalues::FaerBackend;
153#[cfg(feature = "lapack-backend")]
154pub use eigenvalues::LapackBackend;
155#[cfg(feature = "nalgebra-backend")]
156pub use eigenvalues::NalgebraBackend;
157pub mod error;
158use error::Result;
159mod extrema;
160use extrema::*;
161mod fir_types;
162use fir_types::*;
163#[cfg(feature = "lapack-backend")]
164mod lapack;
165#[cfg(feature = "lapack-backend")]
166pub use lapack::{IsLapack, ToLapack};
167pub mod order_estimates;
168#[cfg(feature = "python")]
169mod python;
170mod requirements;
171pub use requirements::{BandSetting, Setting, constant, function, linear, pm_parameters};
172mod types;
173pub use types::{Band, DesignParameters, PMDesign, PMParameters, ParametersBuilder, Symmetry};
174
175/// Parks-McClellan Remez exchange algorithm.
176///
177/// This function runs the Parks-McClellan Remez exchange algorithm to try to
178/// find the optimal FIR filter that minimizes the maximum weighted error in
179/// some sub-bands of the interval [0.0, 0.5], according to the configuration
180/// parameters given in the `parameters` argument.
181///
182/// The type parameter `T` represents the scalar used internally in all the
183/// computations, except potentially when doing eigenvalue calculations.
184///
185/// The type parameter `P` represents the type of the Parks-McClellan design
186/// parameters. It needs to implement the [`DesignParameters`] trait. The
187/// `pm_remez` function uses the methods defined by this trait to obtain the
188/// parameters that it needs.
189///
190/// This function uses the [`DefaultEigenvalueBackend`] to compute
191/// eigenvalues. The backend that is selected as default backend depends on the
192/// feature flags. Use [`pm_remez_with_backend`] to specify a particular
193/// eigenvalue backend.
194///
195/// # Examples
196///
197/// See the [crate-level examples](crate#examples) for examples about how to use
198/// this function in each of the two coding styles provided by this crate.
199#[cfg(any(
200 feature = "lapack-backend",
201 feature = "faer-backend",
202 feature = "nalgebra-backend"
203))]
204pub fn pm_remez<T, P>(parameters: &P) -> Result<PMDesign<T>>
205where
206 T: Float + FloatConst,
207 P: DesignParameters<T>,
208 DefaultEigenvalueBackend: EigenvalueBackend<T>,
209{
210 pm_remez_with_backend(parameters, &DefaultEigenvalueBackend::default())
211}
212
213/// Parks-McClellan Remez exchange algorithm with eigenvalue backend.
214///
215/// This function behaves like [`pm_remez`], but it additionally allows an
216/// eigenvalue backend to be specified. The eigenvalue backend must support the
217/// scalar type `T` that is used. See the [`EigenvalueBackend`] trait for more
218/// details.
219pub fn pm_remez_with_backend<T, P, B>(parameters: &P, eigenvalue_backend: &B) -> Result<PMDesign<T>>
220where
221 T: Float + FloatConst,
222 P: DesignParameters<T>,
223 B: EigenvalueBackend<T>,
224{
225 let bands = parameters.bands();
226 check_bands(bands)?;
227 let mut bands = sort_bands(bands);
228 let num_taps = parameters.num_taps();
229 let odd_length = num_taps % 2 != 0;
230 // Check that the frequency response is realizable by the requested FIR type.
231 let desired_response = parameters.desired_response();
232 let symmetry = parameters.symmetry();
233 check_response(&bands, &desired_response, symmetry, odd_length)?;
234 // Adjust bands to avoid singularities.
235 adjust_bands(&mut bands, symmetry, odd_length);
236 // Convert bands from cycles/sample to radians/sample.
237 for band in bands.iter_mut() {
238 band.convert_to_radians();
239 }
240
241 // Adjust desired response and weights depending on FIR type. See Fig. 2 in
242 // [3]. This also converts the argument of these functions from
243 // cycles/sample to rad/sample.
244 let desired = adjust_desired(desired_response, symmetry, odd_length);
245 let weights = parameters.weights();
246 let weights = adjust_weights(weights, symmetry, odd_length);
247
248 // Number of cosine functions to use in approximation (n in [3]).
249 let num_functions = match (symmetry, odd_length) {
250 (Symmetry::Even, true) => num_taps / 2 + 1,
251 _ => num_taps / 2,
252 };
253
254 // Calculate initial parameters
255
256 let mut extremal_freqs = initial_extremal_freqs(&bands, num_functions);
257 // x = cos(f), where f are the extremal freqs
258 let mut x: Vec<T> = extremal_freqs.iter().map(|f| f.cos()).collect();
259 let mut wk: Vec<T> = compute_barycentric_weights(&x).collect();
260 let mut desired_x: Vec<T> = extremal_freqs.iter().map(|&f| desired(f)).collect();
261 let mut weights_x: Vec<T> = extremal_freqs.iter().map(|&f| weights(f)).collect();
262 let mut delta = compute_delta(&wk, &desired_x, &weights_x);
263 let mut yk: Vec<T> = compute_lagrange_abscisa(delta, &desired_x, &weights_x).collect();
264 let mut num_iterations = 0;
265 let mut flatness = T::zero();
266 let max_iterations = parameters.max_iterations();
267 let cheby_proxy_m = parameters.chebyshev_proxy_degree();
268 let flatness_threshold = parameters.flatness_threshold();
269 for num_iter in 1..=max_iterations {
270 num_iterations = num_iter;
271 // Perform Remez exchange
272
273 // Convert bands edges using x = cos(f). Note that cos() is decreasing, so
274 // we use rev() and swap end and begin to obtain an output in increasing
275 // order.
276 let bands_x: Vec<Interval<T>> = bands
277 .iter()
278 .rev()
279 .map(|b| Interval {
280 begin: b.end().cos(),
281 end: b.begin().cos(),
282 })
283 .collect();
284 let subintervals = subdivide(&x, &bands_x);
285 // TODO: use with_capacity with an upper estimate
286 let mut remez_candidates: Vec<ExtremaCandidate<T>> = Vec::new();
287
288 // Add subinterval endpoints to the candidate list
289 remez_candidates.extend(subintervals.iter().flat_map(|interval| {
290 [
291 compute_extrema_candidate(interval.begin, &x, &wk, &yk, &desired, &weights),
292 compute_extrema_candidate(interval.end, &x, &wk, &yk, &desired, &weights),
293 ]
294 .into_iter()
295 }));
296
297 // Compute Chebyshev nodes for [-1, 1] interval
298 let cheby_nodes: Vec<T> = {
299 let scale = T::PI() / T::from(cheby_proxy_m).unwrap();
300 (0..=cheby_proxy_m)
301 .map(|j| (T::from(j).unwrap() * scale).cos())
302 .collect()
303 };
304 // Add local extrema inside each subinterval to the candidate list
305 for interval in &subintervals {
306 remez_candidates.extend(find_extrema_in_subinterval(
307 interval,
308 &cheby_nodes,
309 &x,
310 &wk,
311 &yk,
312 &desired,
313 &weights,
314 eigenvalue_backend,
315 )?);
316 }
317
318 // Sort candidates
319 // unwrap will fail if there are NaN's in the x values
320 remez_candidates.sort_unstable_by(|a, b| a.x.partial_cmp(&b.x).unwrap());
321
322 // Prune extrema candidates to leave only num_functions + 1 of them
323 let remez_candidates = prune_extrema_candidates(&remez_candidates, num_functions + 1)?;
324
325 // Find largest and smallest error value in extrema candidates to assess convergence
326 let MinMaxResult::MinMax(min_error, max_error) =
327 remez_candidates.iter().map(|a| a.error.abs()).minmax()
328 else {
329 panic!("remez_candidates has two few elements to obtain minmax()")
330 };
331 flatness = (max_error - min_error) / max_error;
332
333 // Set new extremal frequencies from candidates
334 for ((f, x0), candidate) in extremal_freqs
335 .iter_mut()
336 .zip(x.iter_mut())
337 // rev is used because acos() is a decreasing function
338 .zip(remez_candidates.iter().rev())
339 {
340 *x0 = candidate.x;
341 *f = candidate.x.acos();
342 }
343 // Compute new barycentric weights
344 for (dst, src) in wk.iter_mut().zip(compute_barycentric_weights(&x)) {
345 *dst = src;
346 }
347 // Compute new desired and weights
348 for (des, &f) in desired_x.iter_mut().zip(extremal_freqs.iter()) {
349 *des = desired(f);
350 }
351 for (wei, &f) in weights_x.iter_mut().zip(extremal_freqs.iter()) {
352 *wei = weights(f);
353 }
354 // Compute new delta
355 delta = compute_delta(&wk, &desired_x, &weights_x);
356 // Compute new y_k
357 for (dst, src) in yk
358 .iter_mut()
359 .zip(compute_lagrange_abscisa(delta, &desired_x, &weights_x))
360 {
361 *dst = src
362 }
363
364 if flatness <= flatness_threshold {
365 // Convergence reached
366 break;
367 }
368 }
369
370 // Obtain the time-domain coefficients.
371 //
372 // This can be done by evaluating H(f) at the Chebyshev nodes of the second
373 // kind, f = cos(k*pi/n), where H(f) = \sum_{0 <= k <= n} a_k * cos(k*f),
374 // and then computing a_k as the coefficients in the expansion of H(cos(x))
375 // in terms of Chebyshev polynomials of the first kind.
376 let mut ck: Vec<T> = {
377 let scale = T::PI() / T::from(num_functions - 1).unwrap();
378 (0..num_functions)
379 .map(|j| compute_freq_response((T::from(j).unwrap() * scale).cos(), &x, &wk, &yk))
380 .collect()
381 };
382 let ak = compute_cheby_coefficients(&mut ck);
383
384 // Convert extremal frequencies from radians/sample to cycles/sample.
385 for f in extremal_freqs.iter_mut() {
386 *f = *f / T::TAU();
387 }
388
389 Ok(PMDesign {
390 impulse_response: h_from_ak(&ak, num_taps, symmetry, odd_length),
391 weighted_error: delta.abs(),
392 extremal_freqs,
393 num_iterations,
394 flatness,
395 })
396}