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(&parameters)?;
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(&parameters)?;
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}