constfilt
Compile-time IIR digital filter design for C++17
Loading...
Searching...
No Matches
elliptic.hpp
1#ifndef CONSTFILT_ELLIPTIC_HPP
2#define CONSTFILT_ELLIPTIC_HPP
3
4#include "analog_filter.hpp"
5#include "vendor/consteig/consteig.hpp"
6#include "vendor/gcem_wrapper.hpp"
7
8namespace constfilt
9{
10
11// Elliptic (Cauer) IIR filter of order N.
12//
13// Equiripple in both passband and stopband; minimum-order for a given
14// Rp / Rs specification.
15//
16// Template parameters:
17// T - floating-point scalar type
18// N - filter order (>= 1)
19// Method - discretization method (Tustin default), ZOH, or MatchedZ
20// FilterType - LowPass (default) or HighPass
21//
22// Constructor parameters:
23// cutoff_hz - passband edge (-Rp dB point)
24// ripple_db - passband ripple Rp in dB (e.g. 0.5)
25// attenuation_db - stopband attenuation Rs in dB (e.g. 40)
26// sample_rate_hz - sample rate in Hz
27//
28// The implementation follows Octave's ncauer (theta-function / q-series path),
29// with one deviation: Step 2 uses the modular identity q = q1^(1/N) instead of
30// ncauer's iterative degree-equation solver. Steps 1 and 3 onward are
31// identical. All coefficient math is constexpr.
32template <typename T, consteig::Size N, typename Method = Tustin,
33 typename FilterType = LowPass>
34class Elliptic : public AnalogFilter<T, N, Method>
35{
36 static_assert(N >= 1u, "Elliptic order must be at least 1");
37
38 public:
39 constexpr Elliptic(T cutoff_hz, T ripple_db, T attenuation_db,
40 T sample_rate_hz)
41 : AnalogFilter<T, N, Method>(
42 compute_continuous_tf(cutoff_hz, ripple_db, attenuation_db),
43 sample_rate_hz)
44 {
45 }
46
47 private:
48 using Complex = consteig::Complex<T>;
49
50 // Number of complex-conjugate pole/zero pairs: floor(N/2).
51 static constexpr consteig::Size M{N / 2u};
52
53 // ln(10)/10: converts dB power ratio to natural-log exponent (10^(x/10) =
54 // exp(x*ln10/10)).
55 static constexpr double LN10_OVER_10{0.23025850929940457};
56 // AGM iteration count for elliptic_K; 64 rounds gives full double
57 // precision.
58 static constexpr int AGM_ITERATIONS{64};
59 // Truncation depth for theta-function and nome q-series. Since q < 1,
60 // terms decay as q^(n^2) and are below double precision well before n=30.
61 // Any value >= ~15 would give the same result; 30 is a conservative margin.
62 static constexpr int SERIES_TERMS{30};
63 // Coefficients of the nome q-series: q = q0 + 2*q0^5 + 15*q0^9 + 150*q0^13.
64 static constexpr int NOME_COEFF_Q0_5{2};
65 static constexpr int NOME_COEFF_Q0_9{15};
66 static constexpr int NOME_COEFF_Q0_13{150};
67
68 static constexpr TransferFunction<T, N + 1u, N + 1u> compute_continuous_tf(
69 T cutoff_hz, T ripple_db, T attenuation_db)
70 {
71 const T wc = static_cast<T>(2) * static_cast<T>(GCEM_PI) * cutoff_hz;
72 TransferFunction<T, N + 1u, N + 1u> tf{};
73 elliptic_tf(wc, ripple_db, attenuation_db, tf.b, tf.a, FilterType{});
74 return tf;
75 }
76
77 // Math helpers
78
79 // Complete elliptic integral of the first kind K(k) via AGM
80 // (https://dlmf.nist.gov/19.2#ii).
81 // K(k) = pi / (2 * AGM(1, sqrt(1-k^2)))
82 static constexpr T elliptic_K(T k)
83 {
84 T a = static_cast<T>(1);
85 T b = gcem::sqrt(static_cast<T>(1) - k * k);
86 for (int i = 0; i < AGM_ITERATIONS; ++i)
87 {
88 const T a2 = (a + b) / static_cast<T>(2);
89 const T b2 = gcem::sqrt(a * b);
90 a = a2;
91 b = b2;
92 }
93 return static_cast<T>(GCEM_PI) / (static_cast<T>(2) * a);
94 }
95
96 // Convert a power ratio in dB to linear: 10^(x/10) = exp(x * ln10/10).
97 static constexpr T from_db10(T x)
98 {
99 return gcem::exp(x * static_cast<T>(LN10_OVER_10));
100 }
101
102 // Nome q from modulus k (ncauer q-series approximation).
103 // q0 = 0.5 * (1 - sqrt(k')) / (1 + sqrt(k'))
104 // q = q0 + 2*q0^5 + 15*q0^9 + 150*q0^13
105 static constexpr T compute_nome(T k)
106 {
107 const T kp = gcem::sqrt(static_cast<T>(1) - k * k);
108 const T sqrt_kp = gcem::sqrt(kp);
109 const T q0 = static_cast<T>(0.5) * (static_cast<T>(1) - sqrt_kp) /
110 (static_cast<T>(1) + sqrt_kp);
111 const T q0_2 = q0 * q0;
112 const T q0_4 = q0_2 * q0_2;
113 const T q0_5 = q0_4 * q0;
114 const T q0_9 = q0_5 * q0_4;
115 const T q0_13 = q0_9 * q0_4;
116 return q0 + static_cast<T>(NOME_COEFF_Q0_5) * q0_5 +
117 static_cast<T>(NOME_COEFF_Q0_9) * q0_9 +
118 static_cast<T>(NOME_COEFF_Q0_13) * q0_13;
119 }
120
121 // Recover modulus k from nome q (invert q = exp(-pi*K(k')/K(k)),
122 // where K(k) and K(k') are the complete elliptic integrals
123 // https://dlmf.nist.gov/19.2#ii).
124 // No closed form exists for this inversion, so Jacobi theta functions
125 // (https://dlmf.nist.gov/20.2#i) are used -- power series in q whose
126 // ratio gives k exactly via the identity k = theta2^2/theta3^2
127 // (https://dlmf.nist.gov/22.2, Whittaker & Watson ch. 22, Zverev s4.3):
128 // theta2(q) = 2*q^(1/4) * sum_{n=0}^{inf} q^{n(n+1)}
129 // theta3(q) = 1 + 2*sum_{n=1}^{inf} q^{n^2}
130 // k = (theta2/theta3)^2
131 static constexpr T modulus_from_nome(T q)
132 {
133 const T q14 = gcem::sqrt(gcem::sqrt(q)); // q^(1/4)
134 const T q2 = q * q;
135
136 // theta2(0,q) power series: 2*q^(1/4) * sum_{n=0}^{inf} q^{n(n+1)}
137 // Derived from the DLMF form 2*sum q^{(n+1/2)^2} by factoring out
138 // q^(1/4) since (n+1/2)^2 = n(n+1) + 1/4.
139 T theta2 = static_cast<T>(0);
140 T qpow = static_cast<T>(1); // q^(n*(n+1)), starts at q^0 when n=0
141 T q_2n = static_cast<T>(1);
142 for (int n = 0; n <= SERIES_TERMS; ++n)
143 {
144 if (n > 0)
145 {
146 q_2n *= q2;
147 qpow *= q_2n;
148 }
149 theta2 += qpow;
150 }
151 theta2 *= static_cast<T>(2) * q14;
152
153 // theta3(0,q) power series: 1 + 2*sum_{n=1}^{inf} q^{n^2}
154 T theta3 = static_cast<T>(1);
155 T qpow3 = q; // q^(n^2), starting at q^1
156 T q_2n1 = q; // q^(2n-1), starting at q^1
157 for (int n = 1; n <= SERIES_TERMS; ++n)
158 {
159 if (n > 1)
160 {
161 q_2n1 *= q2;
162 qpow3 *= q_2n1;
163 }
164 theta3 += static_cast<T>(2) * qpow3;
165 }
166
167 const T ratio = theta2 / theta3;
168 return ratio * ratio;
169 }
170
171 // Pole-shift parameter sig0 via theta-function series (ncauer algorithm).
172 //
173 // l = (1/(2N)) * log((10^(0.05*Rp) + 1) / (10^(0.05*Rp) - 1))
174 // sig01 = sum_{m=0..30} (-1)^m * q^(m(m+1)) * sinh((2m+1)*l)
175 // sig02 = sum_{m=1..30} (-1)^m * q^(m^2) * cosh(2*m*l)
176 // sig0 = abs(2 * q^(1/4) * sig01 / (1 + 2*sig02))
177 static constexpr T compute_sig0(T ripple_db, T q)
178 {
179 const T gain = from_db10(ripple_db / static_cast<T>(2)); // from_db10
180 // divides by
181 // 10; so to
182 // divide by 20
183 // we only need
184 // to divide by
185 // 2 here
186
187 // hyperbolic angle encoding ripple spec, spread across N poles it is
188 // the imaginary argument at which we evaluate the Jacobi theta
189 // functions used from step 2.(https://dlmf.nist.gov/20.2#E1)
190 const T l =
191 gcem::log((gain + static_cast<T>(1)) / (gain - static_cast<T>(1))) /
192 (static_cast<T>(2) * static_cast<T>(N));
193
194 const T q2 = q * q; // q^2
195
196 // sig01: q^(m*(m+1)) incremental via ratio q^(2m).
197 T sig01 = static_cast<T>(0);
198 T qpow1 = static_cast<T>(1); // q^(m*(m+1)), starts at q^0
199 T q_2m = static_cast<T>(1); // q^(2m), updated before use
200 for (int m = 0; m <= SERIES_TERMS; ++m)
201 {
202 if (m > 0)
203 {
204 q_2m *= q2; // q^2, q^4, q^6, ...
205 qpow1 *= q_2m; // q^2, q^6, q^12, ...
206 }
207 const T sign =
208 (m % 2 == 0) ? static_cast<T>(1) : static_cast<T>(-1);
209 const T x = static_cast<T>(2 * m + 1) * l;
210 sig01 += sign * qpow1 * gcem::sinh(x);
211 }
212
213 // sig02: q^(m^2) incremental via ratio q^(2m-1).
214 T sig02 = static_cast<T>(0);
215 T qpow2 = q; // q^(1^2) = q
216 T q_2m1 = q; // q^(2*1-1) = q
217 for (int m = 1; m <= SERIES_TERMS; ++m)
218 {
219 if (m > 1)
220 {
221 q_2m1 *= q2; // q^3, q^5, q^7, ...
222 qpow2 *= q_2m1; // q^4, q^9, q^16, ...
223 }
224 const T sign =
225 (m % 2 == 0) ? static_cast<T>(1) : static_cast<T>(-1);
226 const T x = static_cast<T>(2 * m) * l;
227 sig02 += sign * qpow2 * gcem::cosh(x);
228 }
229
230 const T q14 = gcem::sqrt(gcem::sqrt(q)); // q^(1/4)
231
232 const T sig0 = static_cast<T>(2) * q14 * sig01 /
233 (static_cast<T>(1) + static_cast<T>(2) * sig02);
234
235 return (sig0 < static_cast<T>(0)) ? -sig0 : sig0;
236 }
237
238 // Compute zero position wi via theta-function series (ncauer algorithm).
239 //
240 // mu = ii (odd N), mu = ii - 0.5 (even N)
241 // soma1 = sum_{m=0..30} 2*q^(1/4)*(-1)^m*q^(m(m+1))*sin((2m+1)*pi*mu/N)
242 // soma2 = sum_{m=1..30} 2*(-1)^m*q^(m^2)*cos(2*m*pi*mu/N)
243 // wi = soma1 / (1 + soma2)
244 static constexpr T compute_wi(consteig::Size ii, T q)
245 {
246 const T mu = (N % 2u == 1u) ? static_cast<T>(ii)
247 : static_cast<T>(ii) - static_cast<T>(0.5);
248 const T q14 = gcem::sqrt(gcem::sqrt(q)); // q^(1/4)
249 const T q2 = q * q;
250 const T pi_mu_n = static_cast<T>(GCEM_PI) * mu / static_cast<T>(N);
251
252 // soma1: q^(m*(m+1)) incremental.
253 T soma1 = static_cast<T>(0);
254 T qpow1 = static_cast<T>(1);
255 T q_2m = static_cast<T>(1);
256 for (int m = 0; m <= SERIES_TERMS; ++m)
257 {
258 if (m > 0)
259 {
260 q_2m *= q2;
261 qpow1 *= q_2m;
262 }
263 const T sign =
264 (m % 2 == 0) ? static_cast<T>(1) : static_cast<T>(-1);
265 const T arg = static_cast<T>(2 * m + 1) * pi_mu_n;
266 soma1 += sign * qpow1 * gcem::sin(arg);
267 }
268 soma1 *= static_cast<T>(2) * q14;
269
270 // soma2: q^(m^2) incremental.
271 T soma2 = static_cast<T>(0);
272 T qpow2 = q;
273 T q_2m1 = q;
274 for (int m = 1; m <= SERIES_TERMS; ++m)
275 {
276 if (m > 1)
277 {
278 q_2m1 *= q2;
279 qpow2 *= q_2m1;
280 }
281 const T sign =
282 (m % 2 == 0) ? static_cast<T>(1) : static_cast<T>(-1);
283 const T arg = static_cast<T>(2 * m) * pi_mu_n;
284 soma2 += sign * qpow2 * gcem::cos(arg);
285 }
286 soma2 *= static_cast<T>(2);
287
288 return soma1 / (static_cast<T>(1) + soma2);
289 }
290
291 // In-place multiply polynomial poly (ascending order, currently degree
292 // deg-1) by (s - root), bringing it to degree deg. Builds up the full
293 // polynomial one root at a time: start with (s - r1), call with r2 to get
294 // (s - r1)(s - r2), call again with r3 to get (s - r1)(s - r2)(s - r3).
295 static constexpr void poly_mul_root(Complex (&poly)[N + 1u],
296 consteig::Size deg, Complex root)
297 {
298 for (consteig::Size j = deg; j > 0u; --j)
299 {
300 poly[j] = poly[j - 1u] - root * poly[j];
301 }
302 poly[0] =
303 Complex{static_cast<T>(0), static_cast<T>(0)} - root * poly[0];
304 }
305
306 // Low-pass elliptic transfer function (ncauer theta-function algorithm).
307 //
308 // Steps:
309 // 1. Compute nome q from k1 via modular identity: q = q1^(1/N).
310 // 2. Recover design modulus k from q via theta functions.
311 // 3. Pole-shift sig0 via theta series.
312 // 4. Zero positions wi via theta series.
313 // 5. Build s-domain polynomials from poles/zeros, scale by sqrt(ws).
314 // 6. Gain normalization: H(0)=1 (odd N), H(0)=Gp (even N).
315 // 7. Scale for passband cutoff wc.
316 //
317 // The filter is fully determined after step 4. Steps 2-4 are the elliptic
318 // function machinery that places poles and zeros for equiripple in both
319 // bands. Step 1 is unit conversion; steps 5-7 are extraction and scaling.
320 static constexpr void elliptic_tf(T wc, T ripple_db, T attenuation_db,
321 T (&b)[N + 1u], T (&a)[N + 1u], LowPass)
322 {
323 // Step 1: selectivity ratio k1 = ep/es.
324 // ep = sqrt(10^(Rp/10) - 1), es = sqrt(10^(Rs/10) - 1)
325 const T ep = gcem::sqrt(from_db10(ripple_db) - static_cast<T>(1));
326 const T es = gcem::sqrt(from_db10(attenuation_db) - static_cast<T>(1));
327 const T k1 = ep / es;
328
329 // Step 2: find design modulus k.
330 // q1 = exp(-pi * K(k1') / K(k1)) nome of k1
331 // q = q1^(1/N) modular equation (Zverev
332 // s4.3) k = (theta2(q) / theta3(q))^2 recover modulus from
333 // nome
334 const T q1 = compute_nome(k1);
335 const T q = gcem::exp(gcem::log(q1) / static_cast<T>(N));
336 const T k = modulus_from_nome(q);
337
338 // Step 3: pole-shift parameter sig0.
339 // Controls how far poles sit in the LHP, setting the equiripple
340 // level. Moving sigma further left makes the filter more dampled,
341 // which is what changes the ripple level.
342 // Derived from sn(j*K(k')*l,
343 // k) via theta series, where l = acoth(10^(Rp/20)) / N.
344 const T sig0 = compute_sig0(ripple_db, q);
345
346 // Step 4: zero and pole positions for each conjugate pair ii=1..M.
347 // ws = 1/k normalized stopband edge (>1)
348 // wi = sn(mu*K(k)/N, k) via theta series (zero/pole spacing in
349 // elliptic frequency
350 // space)
351 // Vi = cn(mu*K(k)/N, k) * dn(mu*K(k)/N, k)
352 // w = sqrt((1 + k*sig0^2)(1 + sig0^2/k))
353 //
354 // zeros: +/-j * sqrt(ws) / wi (on the imaginary axis)
355 // poles: sqrt(ws) * (-sig0*Vi +/- j*wi*w) / (1 + sig0^2*wi^2)
356 // real pole (odd N only): -sig0 * sqrt(ws)
357 const T ws = static_cast<T>(1) / k;
358 const T sqrt_ws = gcem::sqrt(ws);
359 const T w = gcem::sqrt((static_cast<T>(1) + k * sig0 * sig0) *
360 (static_cast<T>(1) + sig0 * sig0 / k));
361
362 Complex poly_a[N + 1u]{};
363 Complex poly_b[N + 1u]{};
364 poly_a[0] = Complex{static_cast<T>(1), static_cast<T>(0)};
365 poly_b[0] = Complex{static_cast<T>(1), static_cast<T>(0)};
366
367 consteig::Size deg_a = 0u;
368 consteig::Size deg_b = 0u;
369
370 for (consteig::Size ii = 1u; ii <= M; ++ii)
371 {
372 const T wi = compute_wi(ii, q);
373 const T Vi = gcem::sqrt((static_cast<T>(1) - k * wi * wi) *
374 (static_cast<T>(1) - wi * wi / k));
375
376 const T omega_z = sqrt_ws / wi;
377 ++deg_b;
378 poly_mul_root(poly_b, deg_b, Complex{static_cast<T>(0), omega_z});
379 ++deg_b;
380 poly_mul_root(poly_b, deg_b, Complex{static_cast<T>(0), -omega_z});
381
382 const T denom = static_cast<T>(1) + sig0 * sig0 * wi * wi;
383 const T p_re = sqrt_ws * (-sig0 * Vi) / denom;
384 const T p_im = sqrt_ws * (wi * w) / denom;
385
386 ++deg_a;
387 poly_mul_root(poly_a, deg_a, Complex{p_re, p_im});
388 ++deg_a;
389 poly_mul_root(poly_a, deg_a, Complex{p_re, -p_im});
390 }
391
392 if (N % 2u == 1u)
393 {
394 ++deg_a;
395 poly_mul_root(poly_a, deg_a,
396 Complex{-sig0 * sqrt_ws, static_cast<T>(0)});
397 }
398
399 // Step 5: convert ascending -> descending; take real parts (imag ~ 0).
400 for (consteig::Size i = 0u; i <= N; ++i)
401 {
402 a[i] = poly_a[N - i].real;
403 b[i] = poly_b[N - i].real;
404 }
405
406 // Step 6: gain normalization.
407 // odd N -> H(0) = 1 (DC is a ripple peak)
408 // even N -> H(0) = Gp = 1/sqrt(1+ep^2) (DC is a ripple trough)
409 const T Gp =
410 static_cast<T>(1) / gcem::sqrt(static_cast<T>(1) + ep * ep);
411 const T H0 = (N % 2u == 1u) ? static_cast<T>(1) : Gp;
412 const T gain = H0 * a[N] / b[N];
413 for (consteig::Size i = 0u; i <= N; ++i)
414 {
415 b[i] *= gain;
416 }
417
418 // Step 7: scale for passband cutoff wc.
419 // Substituting s -> s/wc multiplies the coefficient of s^(N-i) by
420 // wc^i.
421 for (consteig::Size i = 0u; i <= N; ++i)
422 {
423 const T sc = gcem::pow(wc, static_cast<int>(i));
424 a[i] *= sc;
425 b[i] *= sc;
426 }
427 }
428
429 // High-pass via LP-to-HP transform.
430 //
431 // Computes the normalized LP prototype (wc = 1 rad/s), then:
432 // a_hp[j] = a_lp[N-j] * wc^j
433 // b_hp[j] = b_lp[N-j] * wc^j
434 static constexpr void elliptic_tf(T wc, T ripple_db, T attenuation_db,
435 T (&b)[N + 1u], T (&a)[N + 1u], HighPass)
436 {
437 T b_lp[N + 1u]{};
438 T a_lp[N + 1u]{};
439 elliptic_tf(static_cast<T>(1), ripple_db, attenuation_db, b_lp, a_lp,
440 LowPass{});
441
442 for (consteig::Size j = 0u; j <= N; ++j)
443 {
444 const T sc = gcem::pow(wc, static_cast<int>(j));
445 a[j] = a_lp[N - j] * sc;
446 b[j] = b_lp[N - j] * sc;
447 }
448 }
449};
450
451} // namespace constfilt
452
453#endif // CONSTFILT_ELLIPTIC_HPP