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 - TustinPW (default), TustinNW, 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 = TustinPW,
33 typename FilterType = LowPass>
34class Elliptic
35 : public AnalogFilter<T, N, typename bind_method<T, Method>::type>
36{
37 static_assert(N >= 1u, "Elliptic order must be at least 1");
38
39 using BoundMethod = typename bind_method<T, Method>::type;
40
41 public:
42 constexpr Elliptic(T cutoff_hz, T ripple_db, T attenuation_db,
43 T sample_rate_hz)
44 : AnalogFilter<T, N, BoundMethod>(
45 compute_continuous_tf(cutoff_hz, ripple_db, attenuation_db),
46 compute_factored_tf(cutoff_hz, ripple_db, attenuation_db,
47 FilterType{}),
48 sample_rate_hz, make_tustin_tag(cutoff_hz, BoundMethod{}))
49 {
50 }
51
52 private:
53 using Complex = consteig::Complex<T>;
54
55 // Number of complex-conjugate pole/zero pairs: floor(N/2).
56 static constexpr consteig::Size M{N / 2u};
57
58 // ln(10)/10: converts dB power ratio to natural-log exponent (10^(x/10) =
59 // exp(x*ln10/10)).
60 static constexpr double LN10_OVER_10{0.23025850929940457};
61 // AGM iteration count for elliptic_K; 64 rounds gives full double
62 // precision.
63 static constexpr int AGM_ITERATIONS{64};
64 // Truncation depth for theta-function and nome q-series. Since q < 1,
65 // terms decay as q^(n^2) and are below double precision well before n=30.
66 // Any value >= ~15 would give the same result; 30 is a conservative margin.
67 static constexpr int SERIES_TERMS{30};
68 // Coefficients of the nome q-series: q = q0 + 2*q0^5 + 15*q0^9 + 150*q0^13.
69 static constexpr int NOME_COEFF_Q0_5{2};
70 static constexpr int NOME_COEFF_Q0_9{15};
71 static constexpr int NOME_COEFF_Q0_13{150};
72
73 static constexpr TransferFunction<T, N + 1u, N + 1u> compute_continuous_tf(
74 T cutoff_hz, T ripple_db, T attenuation_db)
75 {
76 const T wc = static_cast<T>(2) * static_cast<T>(GCEM_PI) * cutoff_hz;
77 TransferFunction<T, N + 1u, N + 1u> tf{};
78 elliptic_tf(wc, ripple_db, attenuation_db, tf.b, tf.a, FilterType{});
79 return tf;
80 }
81
82 // Math helpers
83
84 // Complete elliptic integral of the first kind K(k) via AGM
85 // (https://dlmf.nist.gov/19.2#ii).
86 // K(k) = pi / (2 * AGM(1, sqrt(1-k^2)))
87 static constexpr T elliptic_K(T k)
88 {
89 T a = static_cast<T>(1);
90 T b = gcem::sqrt(static_cast<T>(1) - k * k);
91 for (int i = 0; i < AGM_ITERATIONS; ++i)
92 {
93 const T a2 = (a + b) / static_cast<T>(2);
94 const T b2 = gcem::sqrt(a * b);
95 a = a2;
96 b = b2;
97 }
98 return static_cast<T>(GCEM_PI) / (static_cast<T>(2) * a);
99 }
100
101 // Convert a power ratio in dB to linear: 10^(x/10) = exp(x * ln10/10).
102 static constexpr T from_db10(T x)
103 {
104 return gcem::exp(x * static_cast<T>(LN10_OVER_10));
105 }
106
107 // Nome q from modulus k (ncauer q-series approximation).
108 // q0 = 0.5 * (1 - sqrt(k')) / (1 + sqrt(k'))
109 // q = q0 + 2*q0^5 + 15*q0^9 + 150*q0^13
110 static constexpr T compute_nome(T k)
111 {
112 const T kp = gcem::sqrt(static_cast<T>(1) - k * k);
113 const T sqrt_kp = gcem::sqrt(kp);
114 const T q0 = static_cast<T>(0.5) * (static_cast<T>(1) - sqrt_kp) /
115 (static_cast<T>(1) + sqrt_kp);
116 const T q0_2 = q0 * q0;
117 const T q0_4 = q0_2 * q0_2;
118 const T q0_5 = q0_4 * q0;
119 const T q0_9 = q0_5 * q0_4;
120 const T q0_13 = q0_9 * q0_4;
121 return q0 + static_cast<T>(NOME_COEFF_Q0_5) * q0_5 +
122 static_cast<T>(NOME_COEFF_Q0_9) * q0_9 +
123 static_cast<T>(NOME_COEFF_Q0_13) * q0_13;
124 }
125
126 // Recover modulus k from nome q (invert q = exp(-pi*K(k')/K(k)),
127 // where K(k) and K(k') are the complete elliptic integrals
128 // https://dlmf.nist.gov/19.2#ii).
129 // No closed form exists for this inversion, so Jacobi theta functions
130 // (https://dlmf.nist.gov/20.2#i) are used -- power series in q whose
131 // ratio gives k exactly via the identity k = theta2^2/theta3^2
132 // (https://dlmf.nist.gov/22.2, Whittaker & Watson ch. 22, Zverev s4.3):
133 // theta2(q) = 2*q^(1/4) * sum_{n=0}^{inf} q^{n(n+1)}
134 // theta3(q) = 1 + 2*sum_{n=1}^{inf} q^{n^2}
135 // k = (theta2/theta3)^2
136 static constexpr T modulus_from_nome(T q)
137 {
138 const T q14 = gcem::sqrt(gcem::sqrt(q)); // q^(1/4)
139 const T q2 = q * q;
140
141 // theta2(0,q) power series: 2*q^(1/4) * sum_{n=0}^{inf} q^{n(n+1)}
142 // Derived from the DLMF form 2*sum q^{(n+1/2)^2} by factoring out
143 // q^(1/4) since (n+1/2)^2 = n(n+1) + 1/4.
144 T theta2 = static_cast<T>(0);
145 T qpow = static_cast<T>(1); // q^(n*(n+1)), starts at q^0 when n=0
146 T q_2n = static_cast<T>(1);
147 for (int n = 0; n <= SERIES_TERMS; ++n)
148 {
149 if (n > 0)
150 {
151 q_2n *= q2;
152 qpow *= q_2n;
153 }
154 theta2 += qpow;
155 }
156 theta2 *= static_cast<T>(2) * q14;
157
158 // theta3(0,q) power series: 1 + 2*sum_{n=1}^{inf} q^{n^2}
159 T theta3 = static_cast<T>(1);
160 T qpow3 = q; // q^(n^2), starting at q^1
161 T q_2n1 = q; // q^(2n-1), starting at q^1
162 for (int n = 1; n <= SERIES_TERMS; ++n)
163 {
164 if (n > 1)
165 {
166 q_2n1 *= q2;
167 qpow3 *= q_2n1;
168 }
169 theta3 += static_cast<T>(2) * qpow3;
170 }
171
172 const T ratio = theta2 / theta3;
173 return ratio * ratio;
174 }
175
176 // Pole-shift parameter sig0 via theta-function series (ncauer algorithm).
177 //
178 // l = (1/(2N)) * log((10^(0.05*Rp) + 1) / (10^(0.05*Rp) - 1))
179 // sig01 = sum_{m=0..30} (-1)^m * q^(m(m+1)) * sinh((2m+1)*l)
180 // sig02 = sum_{m=1..30} (-1)^m * q^(m^2) * cosh(2*m*l)
181 // sig0 = abs(2 * q^(1/4) * sig01 / (1 + 2*sig02))
182 static constexpr T compute_sig0(T ripple_db, T q)
183 {
184 const T gain = from_db10(ripple_db / static_cast<T>(2)); // from_db10
185 // divides by
186 // 10; so to
187 // divide by 20
188 // we only need
189 // to divide by
190 // 2 here
191
192 // hyperbolic angle encoding ripple spec, spread across N poles it is
193 // the imaginary argument at which we evaluate the Jacobi theta
194 // functions used from step 2.(https://dlmf.nist.gov/20.2#E1)
195 const T l =
196 gcem::log((gain + static_cast<T>(1)) / (gain - static_cast<T>(1))) /
197 (static_cast<T>(2) * static_cast<T>(N));
198
199 const T q2 = q * q; // q^2
200
201 // sig01: q^(m*(m+1)) incremental via ratio q^(2m).
202 T sig01 = static_cast<T>(0);
203 T qpow1 = static_cast<T>(1); // q^(m*(m+1)), starts at q^0
204 T q_2m = static_cast<T>(1); // q^(2m), updated before use
205 for (int m = 0; m <= SERIES_TERMS; ++m)
206 {
207 if (m > 0)
208 {
209 q_2m *= q2; // q^2, q^4, q^6, ...
210 qpow1 *= q_2m; // q^2, q^6, q^12, ...
211 }
212 const T sign =
213 (m % 2 == 0) ? static_cast<T>(1) : static_cast<T>(-1);
214 const T x = static_cast<T>(2 * m + 1) * l;
215 sig01 += sign * qpow1 * gcem::sinh(x);
216 }
217
218 // sig02: q^(m^2) incremental via ratio q^(2m-1).
219 T sig02 = static_cast<T>(0);
220 T qpow2 = q; // q^(1^2) = q
221 T q_2m1 = q; // q^(2*1-1) = q
222 for (int m = 1; m <= SERIES_TERMS; ++m)
223 {
224 if (m > 1)
225 {
226 q_2m1 *= q2; // q^3, q^5, q^7, ...
227 qpow2 *= q_2m1; // q^4, q^9, q^16, ...
228 }
229 const T sign =
230 (m % 2 == 0) ? static_cast<T>(1) : static_cast<T>(-1);
231 const T x = static_cast<T>(2 * m) * l;
232 sig02 += sign * qpow2 * gcem::cosh(x);
233 }
234
235 const T q14 = gcem::sqrt(gcem::sqrt(q)); // q^(1/4)
236
237 const T sig0 = static_cast<T>(2) * q14 * sig01 /
238 (static_cast<T>(1) + static_cast<T>(2) * sig02);
239
240 return (sig0 < static_cast<T>(0)) ? -sig0 : sig0;
241 }
242
243 // Compute zero position wi via theta-function series (ncauer algorithm).
244 //
245 // mu = ii (odd N), mu = ii - 0.5 (even N)
246 // soma1 = sum_{m=0..30} 2*q^(1/4)*(-1)^m*q^(m(m+1))*sin((2m+1)*pi*mu/N)
247 // soma2 = sum_{m=1..30} 2*(-1)^m*q^(m^2)*cos(2*m*pi*mu/N)
248 // wi = soma1 / (1 + soma2)
249 static constexpr T compute_wi(consteig::Size ii, T q)
250 {
251 const T mu = (N % 2u == 1u) ? static_cast<T>(ii)
252 : static_cast<T>(ii) - static_cast<T>(0.5);
253 const T q14 = gcem::sqrt(gcem::sqrt(q)); // q^(1/4)
254 const T q2 = q * q;
255 const T pi_mu_n = static_cast<T>(GCEM_PI) * mu / static_cast<T>(N);
256
257 // soma1: q^(m*(m+1)) incremental.
258 T soma1 = static_cast<T>(0);
259 T qpow1 = static_cast<T>(1);
260 T q_2m = static_cast<T>(1);
261 for (int m = 0; m <= SERIES_TERMS; ++m)
262 {
263 if (m > 0)
264 {
265 q_2m *= q2;
266 qpow1 *= q_2m;
267 }
268 const T sign =
269 (m % 2 == 0) ? static_cast<T>(1) : static_cast<T>(-1);
270 const T arg = static_cast<T>(2 * m + 1) * pi_mu_n;
271 soma1 += sign * qpow1 * gcem::sin(arg);
272 }
273 soma1 *= static_cast<T>(2) * q14;
274
275 // soma2: q^(m^2) incremental.
276 T soma2 = static_cast<T>(0);
277 T qpow2 = q;
278 T q_2m1 = q;
279 for (int m = 1; m <= SERIES_TERMS; ++m)
280 {
281 if (m > 1)
282 {
283 q_2m1 *= q2;
284 qpow2 *= q_2m1;
285 }
286 const T sign =
287 (m % 2 == 0) ? static_cast<T>(1) : static_cast<T>(-1);
288 const T arg = static_cast<T>(2 * m) * pi_mu_n;
289 soma2 += sign * qpow2 * gcem::cos(arg);
290 }
291 soma2 *= static_cast<T>(2);
292
293 return soma1 / (static_cast<T>(1) + soma2);
294 }
295
296 // In-place multiply polynomial poly (ascending order, currently degree
297 // deg-1) by (s - root), bringing it to degree deg. Builds up the full
298 // polynomial one root at a time: start with (s - r1), call with r2 to get
299 // (s - r1)(s - r2), call again with r3 to get (s - r1)(s - r2)(s - r3).
300 static constexpr void poly_mul_root(Complex (&poly)[N + 1u],
301 consteig::Size deg, Complex root)
302 {
303 for (consteig::Size j = deg; j > 0u; --j)
304 {
305 poly[j] = poly[j - 1u] - root * poly[j];
306 }
307 poly[0] =
308 Complex{static_cast<T>(0), static_cast<T>(0)} - root * poly[0];
309 }
310
311 // Fills poles[] and zeros[] with the normalized (wc=1) prototype poles and
312 // zeros derived from the elliptic machinery (steps 3-4 of elliptic_tf).
313 // Poles: M conjugate pairs + optional real pole for odd N.
314 // Zeros: M conjugate pairs on the imaginary axis (+/-j*omega_z).
315 static constexpr void compute_prototype_poles_zeros(
316 T q, T sig0, T k, Complex (&poles)[N], consteig::Size &pole_cnt,
317 Complex (&zeros)[N], consteig::Size &zero_cnt)
318 {
319 const T ws = static_cast<T>(1) / k;
320 const T sqrt_ws = gcem::sqrt(ws);
321 const T w = gcem::sqrt((static_cast<T>(1) + k * sig0 * sig0) *
322 (static_cast<T>(1) + sig0 * sig0 / k));
323
324 pole_cnt = 0u;
325 zero_cnt = 0u;
326
327 for (consteig::Size ii = 1u; ii <= M; ++ii)
328 {
329 const T wi = compute_wi(ii, q);
330 const T Vi = gcem::sqrt((static_cast<T>(1) - k * wi * wi) *
331 (static_cast<T>(1) - wi * wi / k));
332
333 const T omega_z = sqrt_ws / wi;
334 zeros[zero_cnt++] = Complex{static_cast<T>(0), omega_z};
335 zeros[zero_cnt++] = Complex{static_cast<T>(0), -omega_z};
336
337 const T denom = static_cast<T>(1) + sig0 * sig0 * wi * wi;
338 const T p_re = sqrt_ws * (-sig0 * Vi) / denom;
339 const T p_im = sqrt_ws * (wi * w) / denom;
340 poles[pole_cnt++] = Complex{p_re, p_im};
341 poles[pole_cnt++] = Complex{p_re, -p_im};
342 }
343
344 if (N % 2u == 1u)
345 {
346 poles[pole_cnt++] = Complex{-sig0 * sqrt_ws, static_cast<T>(0)};
347 }
348 }
349
350 // Low-pass elliptic transfer function (ncauer theta-function algorithm).
351 //
352 // Steps:
353 // 1. Compute nome q from k1 via modular identity: q = q1^(1/N).
354 // 2. Recover design modulus k from q via theta functions.
355 // 3. Pole-shift sig0 via theta series.
356 // 4. Zero positions wi via theta series.
357 // 5. Build s-domain polynomials from poles/zeros, scale by sqrt(ws).
358 // 6. Gain normalization: H(0)=1 (odd N), H(0)=Gp (even N).
359 // 7. Scale for passband cutoff wc.
360 //
361 // The filter is fully determined after step 4. Steps 2-4 are the elliptic
362 // function machinery that places poles and zeros for equiripple in both
363 // bands. Step 1 is unit conversion; steps 5-7 are extraction and scaling.
364 static constexpr void elliptic_tf(T wc, T ripple_db, T attenuation_db,
365 T (&b)[N + 1u], T (&a)[N + 1u], LowPass)
366 {
367 // Step 1: selectivity ratio k1 = ep/es.
368 // ep = sqrt(10^(Rp/10) - 1), es = sqrt(10^(Rs/10) - 1)
369 const T ep = gcem::sqrt(from_db10(ripple_db) - static_cast<T>(1));
370 const T es = gcem::sqrt(from_db10(attenuation_db) - static_cast<T>(1));
371 const T k1 = ep / es;
372
373 // Step 2: find design modulus k.
374 // q1 = exp(-pi * K(k1') / K(k1)) nome of k1
375 // q = q1^(1/N) modular equation (Zverev
376 // s4.3) k = (theta2(q) / theta3(q))^2 recover modulus from
377 // nome
378 const T q1 = compute_nome(k1);
379 const T q = gcem::exp(gcem::log(q1) / static_cast<T>(N));
380 const T k = modulus_from_nome(q);
381
382 // Step 3: pole-shift parameter sig0.
383 // Controls how far poles sit in the LHP, setting the equiripple
384 // level. Moving sigma further left makes the filter more dampled,
385 // which is what changes the ripple level.
386 // Derived from sn(j*K(k')*l,
387 // k) via theta series, where l = acoth(10^(Rp/20)) / N.
388 const T sig0 = compute_sig0(ripple_db, q);
389
390 // Step 4: zero and pole positions for each conjugate pair ii=1..M.
391 // ws = 1/k normalized stopband edge (>1)
392 // wi = sn(mu*K(k)/N, k) via theta series (zero/pole spacing in
393 // elliptic frequency
394 // space)
395 // Vi = cn(mu*K(k)/N, k) * dn(mu*K(k)/N, k)
396 // w = sqrt((1 + k*sig0^2)(1 + sig0^2/k))
397 //
398 // zeros: +/-j * sqrt(ws) / wi (on the imaginary axis)
399 // poles: sqrt(ws) * (-sig0*Vi +/- j*wi*w) / (1 + sig0^2*wi^2)
400 // real pole (odd N only): -sig0 * sqrt(ws)
401 const T ws = static_cast<T>(1) / k;
402 const T sqrt_ws = gcem::sqrt(ws);
403 const T w = gcem::sqrt((static_cast<T>(1) + k * sig0 * sig0) *
404 (static_cast<T>(1) + sig0 * sig0 / k));
405
406 Complex poly_a[N + 1u]{};
407 Complex poly_b[N + 1u]{};
408 poly_a[0] = Complex{static_cast<T>(1), static_cast<T>(0)};
409 poly_b[0] = Complex{static_cast<T>(1), static_cast<T>(0)};
410
411 consteig::Size deg_a = 0u;
412 consteig::Size deg_b = 0u;
413
414 for (consteig::Size ii = 1u; ii <= M; ++ii)
415 {
416 const T wi = compute_wi(ii, q);
417 const T Vi = gcem::sqrt((static_cast<T>(1) - k * wi * wi) *
418 (static_cast<T>(1) - wi * wi / k));
419
420 const T omega_z = sqrt_ws / wi;
421 ++deg_b;
422 poly_mul_root(poly_b, deg_b, Complex{static_cast<T>(0), omega_z});
423 ++deg_b;
424 poly_mul_root(poly_b, deg_b, Complex{static_cast<T>(0), -omega_z});
425
426 const T denom = static_cast<T>(1) + sig0 * sig0 * wi * wi;
427 const T p_re = sqrt_ws * (-sig0 * Vi) / denom;
428 const T p_im = sqrt_ws * (wi * w) / denom;
429
430 ++deg_a;
431 poly_mul_root(poly_a, deg_a, Complex{p_re, p_im});
432 ++deg_a;
433 poly_mul_root(poly_a, deg_a, Complex{p_re, -p_im});
434 }
435
436 if (N % 2u == 1u)
437 {
438 ++deg_a;
439 poly_mul_root(poly_a, deg_a,
440 Complex{-sig0 * sqrt_ws, static_cast<T>(0)});
441 }
442
443 // Step 5: convert ascending -> descending; take real parts (imag ~ 0).
444 for (consteig::Size i = 0u; i <= N; ++i)
445 {
446 a[i] = poly_a[N - i].real;
447 b[i] = poly_b[N - i].real;
448 }
449
450 // Step 6: gain normalization.
451 // odd N -> H(0) = 1 (DC is a ripple peak)
452 // even N -> H(0) = Gp = 1/sqrt(1+ep^2) (DC is a ripple trough)
453 const T Gp =
454 static_cast<T>(1) / gcem::sqrt(static_cast<T>(1) + ep * ep);
455 const T H0 = (N % 2u == 1u) ? static_cast<T>(1) : Gp;
456 const T gain = H0 * a[N] / b[N];
457 for (consteig::Size i = 0u; i <= N; ++i)
458 {
459 b[i] *= gain;
460 }
461
462 // Step 7: scale for passband cutoff wc.
463 // Substituting s -> s/wc multiplies the coefficient of s^(N-i) by
464 // wc^i.
465 for (consteig::Size i = 0u; i <= N; ++i)
466 {
467 const T sc = gcem::pow(wc, static_cast<int>(i));
468 a[i] *= sc;
469 b[i] *= sc;
470 }
471 }
472
473 // High-pass via LP-to-HP transform.
474 //
475 // Computes the normalized LP prototype (wc = 1 rad/s), then:
476 // a_hp[j] = a_lp[N-j] * wc^j
477 // b_hp[j] = b_lp[N-j] * wc^j
478 static constexpr void elliptic_tf(T wc, T ripple_db, T attenuation_db,
479 T (&b)[N + 1u], T (&a)[N + 1u], HighPass)
480 {
481 T b_lp[N + 1u]{};
482 T a_lp[N + 1u]{};
483 elliptic_tf(static_cast<T>(1), ripple_db, attenuation_db, b_lp, a_lp,
484 LowPass{});
485
486 for (consteig::Size j = 0u; j <= N; ++j)
487 {
488 const T sc = gcem::pow(wc, static_cast<int>(j));
489 a[j] = a_lp[N - j] * sc;
490 b[j] = b_lp[N - j] * sc;
491 }
492 }
493
494 // LP FactoredTF: prototype poles/zeros scaled by wc; gain from polynomial.
495 static constexpr FactoredTF<T, N> compute_factored_tf(T cutoff_hz,
496 T ripple_db,
497 T attenuation_db,
498 LowPass)
499 {
500 const T wc = static_cast<T>(2) * static_cast<T>(GCEM_PI) * cutoff_hz;
501 const T ep = gcem::sqrt(from_db10(ripple_db) - static_cast<T>(1));
502 const T es = gcem::sqrt(from_db10(attenuation_db) - static_cast<T>(1));
503 const T k1 = ep / es;
504 const T q1 = compute_nome(k1);
505 const T q = gcem::exp(gcem::log(q1) / static_cast<T>(N));
506 const T k = modulus_from_nome(q);
507 const T sig0 = compute_sig0(ripple_db, q);
508
509 Complex poles_proto[N]{};
510 Complex zeros_proto[N]{};
511 consteig::Size pole_cnt = 0u;
512 consteig::Size zero_cnt = 0u;
513 compute_prototype_poles_zeros(q, sig0, k, poles_proto, pole_cnt,
514 zeros_proto, zero_cnt);
515
516 FactoredTF<T, N> factored_tf{};
517 factored_tf.nz = zero_cnt;
518 for (consteig::Size i = 0u; i < pole_cnt; ++i)
519 {
520 factored_tf.poles[i] =
521 Complex{wc * poles_proto[i].real, wc * poles_proto[i].imag};
522 }
523 for (consteig::Size i = 0u; i < zero_cnt; ++i)
524 {
525 factored_tf.zeros[i] =
526 Complex{wc * zeros_proto[i].real, wc * zeros_proto[i].imag};
527 }
528
529 // Gain from the polynomial TF (captures the normalization from steps
530 // 6-7).
531 T b_tmp[N + 1u]{};
532 T a_tmp[N + 1u]{};
533 elliptic_tf(wc, ripple_db, attenuation_db, b_tmp, a_tmp, LowPass{});
534 consteig::Size d_b = 0u;
535 while (d_b <= N && b_tmp[d_b] == static_cast<T>(0))
536 {
537 ++d_b;
538 }
539 factored_tf.gain =
540 (d_b > N) ? static_cast<T>(0) : b_tmp[d_b] / a_tmp[0];
541
542 return factored_tf;
543 }
544
545 // HP FactoredTF: derive from LP prototype via LP-to-HP transform (s ->
546 // wc/s). LP pole p_lp -> HP pole wc/p_lp; LP zero j*omega_z -> HP zero
547 // -j*wc/omega_z. For odd N: one extra zero at s=0 (LP strictly proper -> HP
548 // has zero at origin).
549 static constexpr FactoredTF<T, N> compute_factored_tf(T cutoff_hz,
550 T ripple_db,
551 T attenuation_db,
552 HighPass)
553 {
554 const T wc = static_cast<T>(2) * static_cast<T>(GCEM_PI) * cutoff_hz;
555
556 // Normalized LP prototype at wc=1 (cutoff_hz = 1/(2*pi)).
557 const T norm_cutoff =
558 static_cast<T>(1) / (static_cast<T>(2) * static_cast<T>(GCEM_PI));
559 const FactoredTF<T, N> lp = compute_factored_tf(
560 norm_cutoff, ripple_db, attenuation_db, LowPass{});
561
562 FactoredTF<T, N> factored_tf{};
563
564 // HP poles: wc / lp_pole (complex division)
565 for (consteig::Size i = 0u; i < N; ++i)
566 {
567 const Complex &p = lp.poles[i];
568 const T denom_sq = p.real * p.real + p.imag * p.imag;
569 factored_tf.poles[i] =
570 Complex{wc * p.real / denom_sq, -wc * p.imag / denom_sq};
571 }
572
573 // HP zeros: wc / lp_zero (LP zeros are pure imaginary: {0,
574 // +/-omega_z})
575 consteig::Size hp_nz = 0u;
576 for (consteig::Size i = 0u; i < lp.nz; ++i)
577 {
578 const Complex &z = lp.zeros[i];
579 const T denom_sq = z.real * z.real + z.imag * z.imag;
580 factored_tf.zeros[hp_nz++] =
581 Complex{wc * z.real / denom_sq, -wc * z.imag / denom_sq};
582 }
583 // For odd N: LP->HP adds a zero at s=0 (from the strictly-proper LP).
584 if (N % 2u == 1u)
585 {
586 factored_tf.zeros[hp_nz++] =
587 Complex{static_cast<T>(0), static_cast<T>(0)};
588 }
589 factored_tf.nz = hp_nz;
590
591 // Gain from the HP polynomial TF.
592 T b_tmp[N + 1u]{};
593 T a_tmp[N + 1u]{};
594 elliptic_tf(wc, ripple_db, attenuation_db, b_tmp, a_tmp, HighPass{});
595 consteig::Size d_b = 0u;
596 while (d_b <= N && b_tmp[d_b] == static_cast<T>(0))
597 {
598 ++d_b;
599 }
600 factored_tf.gain =
601 (d_b > N) ? static_cast<T>(0) : b_tmp[d_b] / a_tmp[0];
602
603 return factored_tf;
604 }
605};
606
607} // namespace constfilt
608
609#endif // CONSTFILT_ELLIPTIC_HPP