constfilt
Compile-time IIR digital filter design for C++17
Loading...
Searching...
No Matches
discretize.hpp
1#ifndef CONSTFILT_DISCRETIZE_HPP
2#define CONSTFILT_DISCRETIZE_HPP
3
4#include "vendor/consteig/consteig.hpp"
5#include "vendor/gcem_wrapper.hpp"
6
7namespace constfilt
8{
9
10// Tag types
11
12struct ZOH
13{
14};
15
16struct MatchedZ
17{
18};
19
20struct TustinNW // Bilinear, non-prewarped
21{
22};
23
24// TustinPW is a non-template tag so that users can write
25// Butterworth<double, 2, TustinPW> without repeating the scalar type.
26// Butterworth and Elliptic use bind_method<T, Method> to resolve it to
27// TustinPWData<T>, which carries warp_omega. TustinPWData is not part of
28// the user-facing API.
29struct TustinPW // Prewarped bilinear tag
30{
31};
32
33template <typename T>
34struct TustinPWData // Internal: holds warp_omega after bind_method resolves
35 // TustinPW
36{
37 T warp_omega{}; // rad/s
38};
39
40// Resolves TustinPW (non-template tag) to TustinPWData<T> given the filter's
41// scalar type T. All other method tags pass through unchanged.
42template <typename T, typename M> struct bind_method
43{
44 using type = M;
45};
46
47template <typename T> struct bind_method<T, TustinPW>
48{
49 using type = TustinPWData<T>;
50};
51
52// True only for the TustinPW tag — used by AnalogFilter to static_assert that
53// the no-tag constructors are not used when Method = TustinPW.
54template <typename M> struct is_tustinpw_tag
55{
56 static constexpr bool value = false;
57};
58
59template <> struct is_tustinpw_tag<TustinPW>
60{
61 static constexpr bool value = true;
62};
63
64template <typename M> struct is_zoh_tag
65{
66 static constexpr bool value = false;
67};
68
69template <> struct is_zoh_tag<ZOH>
70{
71 static constexpr bool value = true;
72};
73
74// Build the method tag from a cutoff frequency.
75// For TustinPWData<T>, fills in warp_omega = 2*pi*cutoff_hz.
76// For all other methods, returns a default-constructed tag (cutoff unused).
77template <typename T, typename M> constexpr M make_tustin_tag(T, M)
78{
79 return M{};
80}
81
82template <typename T>
83constexpr TustinPWData<T> make_tustin_tag(T cutoff_hz, TustinPWData<T>)
84{
85 return TustinPWData<T>{static_cast<T>(2) * static_cast<T>(GCEM_PI) *
86 cutoff_hz};
87}
88
89// Creates a TustinPWData<T> tag from a warp frequency in Hz.
90// T is deduced from the argument: prewarp(100.0) -> TustinPWData<double>.
91// Use with AnalogFilter's method-tag constructor to supply the warp frequency
92// explicitly (Butterworth and Elliptic derive it automatically from cutoff_hz).
93template <typename T> constexpr TustinPWData<T> prewarp(T warp_hz)
94{
95 return TustinPWData<T>{static_cast<T>(2) * static_cast<T>(GCEM_PI) *
96 warp_hz};
97}
98
99// Data structures
100
101template <typename T, consteig::Size N> struct StateSpace
102{
103 consteig::Matrix<T, N, N> A{};
104 consteig::Matrix<T, N, 1> B{};
105 consteig::Matrix<T, 1, N> C{};
106 T D{};
107};
108
109template <typename T, consteig::Size NB, consteig::Size NA>
110struct TransferFunction
111{
112 T b[NB]{};
113 T a[NA]{};
114};
115
116// An analog transfer function in factored (pole/zero/gain) form.
117//
118// H(s) = gain * prod_i(s - zeros[i]) / prod_j(s - poles[j])
119//
120// Only the first `nz` entries of zeros[] are meaningful; the rest are
121// zero-initialized and must not be read by callers.
122template <typename T, consteig::Size N> struct FactoredTF
123{
124 consteig::Complex<T> poles[N]{}; // all N analog poles
125 consteig::Complex<T> zeros[N]{}; // first nz finite analog zeros
126 consteig::Size nz{0}; // number of populated zeros entries
127 T gain{}; // k_c = b[d_b] / a[0]
128};
129
130// Matrix exponential
131
132// Shared kernel: given a pre-built eigenvector matrix V and per-eigenvalue
133// exponential factors, compute V * diag(exp_factors) * V^{-1} and return
134// the real part.
135template <typename T, consteig::Size N>
136constexpr consteig::Matrix<T, N, N> spectral_matrix_exp(
137 const consteig::Matrix<consteig::Complex<T>, N, N> &V,
138 const consteig::Complex<T> (&exp_factors)[N])
139{
140 using Complex = consteig::Complex<T>;
141 using ComplexMat_NN = consteig::Matrix<Complex, N, N>;
142 using ComplexMat_N1 = consteig::Matrix<Complex, N, 1>;
143
144 const auto lu_V = consteig::lu(V);
145 ComplexMat_NN V_inv{};
146 for (consteig::Size col = 0; col < N; ++col)
147 {
148 ComplexMat_N1 e_col{};
149 e_col(col, 0) = Complex{static_cast<T>(1), static_cast<T>(0)};
150 auto col_vec = consteig::lu_solve(lu_V, e_col);
151 for (consteig::Size row = 0; row < N; ++row)
152 {
153 V_inv(row, col) = col_vec(row, 0);
154 }
155 }
156
157 ComplexMat_NN result_c{};
158 for (consteig::Size i = 0; i < N; ++i)
159 {
160 for (consteig::Size r = 0; r < N; ++r)
161 {
162 for (consteig::Size c = 0; c < N; ++c)
163 {
164 result_c(r, c) =
165 result_c(r, c) + exp_factors[i] * V(r, i) * V_inv(i, c);
166 }
167 }
168 }
169
170 consteig::Matrix<T, N, N> result{};
171 for (consteig::Size r = 0; r < N; ++r)
172 {
173 for (consteig::Size c = 0; c < N; ++c)
174 {
175 result(r, c) = result_c(r, c).real;
176 }
177 }
178 return result;
179}
180
181// matrix_exp(A) via eigendecomposition:
182// Ad = V * diag(exp(lam_i)) * V^{-1}
183// where V = eigenvectors, lam_i = eigenvalues (complex).
184// Real part extracted at the end (imaginary parts cancel for real A).
185template <typename T, consteig::Size N>
186constexpr consteig::Matrix<T, N, N> matrix_exp(
187 const consteig::Matrix<T, N, N> &A)
188{
189 using Complex = consteig::Complex<T>;
190
191 // 1. Eigenvalues and eigenvectors
192 const auto evals = consteig::eigenvalues(A); // Matrix<Complex, N, 1>
193 const auto V = consteig::eigenvectors(A, evals); // Matrix<Complex, N, N>
194
195 // 2. Per-eigenvalue exponential factors
196 Complex exp_factors[N]{};
197 for (consteig::Size i = 0; i < N; ++i)
198 {
199 exp_factors[i] = consteig::exp(evals(i, 0));
200 }
201
202 // 3-4. Invert V, accumulate spectral sum, extract real part
203 return spectral_matrix_exp<T, N>(V, exp_factors);
204}
205
206// matrix_exp for controllable-canonical-form (CCF) matrices given analytic
207// poles.
208//
209// The CCF matrix A_c has eigenvectors v_i = [1, p_i, p_i^2, ..., p_i^(N-1)]^T
210// where p_i are the analog poles (eigenvalues of A_c). This Vandermonde
211// structure lets us build V analytically from the poles, bypassing
212// consteig::eigenvectors (inverse iteration on the ill-conditioned CCF matrix).
213//
214// exp(Ts*Ac) = V * diag(exp(Ts*p_i)) * V^{-1}
215// V[r][i] = p_i^r (Vandermonde, built from UNSCALED poles)
216// exp factor = exp(Ts * p_i) (spectral scaling)
217template <typename T, consteig::Size N>
218constexpr consteig::Matrix<T, N, N> matrix_exp_ccf(
219 const FactoredTF<T, N> &factored_tf, T Ts)
220{
221 using Complex = consteig::Complex<T>;
222 using ComplexMat_NN = consteig::Matrix<Complex, N, N>;
223
224 ComplexMat_NN V{};
225 for (consteig::Size i = 0; i < N; ++i)
226 {
227 Complex power{static_cast<T>(1), static_cast<T>(0)};
228 for (consteig::Size r = 0; r < N; ++r)
229 {
230 V(r, i) = power;
231 power = power * factored_tf.poles[i];
232 }
233 }
234
235 Complex exp_factors[N]{};
236 for (consteig::Size i = 0; i < N; ++i)
237 {
238 exp_factors[i] = consteig::exp(Complex{Ts * factored_tf.poles[i].real,
239 Ts * factored_tf.poles[i].imag});
240 }
241
242 return spectral_matrix_exp<T, N>(V, exp_factors);
243}
244
245// ZOH discretization
246
247// ZOH: Ad = matrix_exp(Ac*Ts), Bd = Ac^{-1} * (Ad - I) * Bc
248// Solve Ac * Bd = (Ad - I) * Bc via LU.
249// Cc and Dc are unchanged.
250// Returns StateSpace, as opposed to TransferFunction to preserve access to the
251// discrete matrices for callers that need them (e.g. state estimation, observer
252// design).
253template <typename T, consteig::Size N>
254constexpr StateSpace<T, N> zoh_discretize(const StateSpace<T, N> &sys_c, T Ts,
255 ZOH /*tag*/)
256{
257 const auto &Ac = sys_c.A;
258 const auto &Bc = sys_c.B;
259
260 // Ac * Ts
261 const consteig::Matrix<T, N, N> Ad = matrix_exp(Ts * Ac);
262
263 // (Ad - I)
264 const consteig::Matrix<T, N, N> AdmI = Ad - consteig::eye<T, N>();
265
266 // (Ad - I) * Bc
267 const consteig::Matrix<T, N, 1> rhs = AdmI * Bc;
268
269 // Bd = Ac^{-1} * rhs -> solve Ac * Bd = rhs
270 const auto lu_Ac = consteig::lu(Ac);
271 const auto Bd = consteig::lu_solve(lu_Ac, rhs);
272
273 StateSpace<T, N> sys_d{};
274 sys_d.A = Ad;
275 sys_d.B = Bd;
276 sys_d.C = sys_c.C;
277 sys_d.D = sys_c.D;
278 return sys_d;
279}
280
281// ZOH discretization using analytically known poles from a FactoredTF.
282// Bypasses both the QR eigenvalue search and eigenvector inverse iteration
283// by exploiting the Vandermonde structure of the CCF eigenvectors.
284template <typename T, consteig::Size N>
285constexpr StateSpace<T, N> zoh_discretize_with_evals(
286 const StateSpace<T, N> &sys_c, T Ts, const FactoredTF<T, N> &factored_tf)
287{
288 const consteig::Matrix<T, N, N> Ad = matrix_exp_ccf(factored_tf, Ts);
289
290 const consteig::Matrix<T, N, N> AdmI = Ad - consteig::eye<T, N>();
291 const consteig::Matrix<T, N, 1> rhs = AdmI * sys_c.B;
292 const auto lu_Ac = consteig::lu(sys_c.A);
293 const auto Bd = consteig::lu_solve(lu_Ac, rhs);
294
295 StateSpace<T, N> sys_d{};
296 sys_d.A = Ad;
297 sys_d.B = Bd;
298 sys_d.C = sys_c.C;
299 sys_d.D = sys_c.D;
300 return sys_d;
301}
302
303// Characteristic polynomial
304
305// Fills monic characteristic polynomial of Ad:
306// [1, c_1, c_2, ..., c_N] (N+1 coefficients)
307// Uses consteig::eigenvalues to obtain lam_1..lam_N, then builds
308// (z - lam_1)(z - lam_2)...(z - lam_N) in complex arithmetic.
309// Real parts are extracted at the end (imaginary parts cancel for real A).
310template <typename T, consteig::Size N>
311constexpr void char_poly(const consteig::Matrix<T, N, N> &Ad,
312 T (&coeffs)[N + 1u])
313{
314 using Complex = consteig::Complex<T>;
315
316 const auto evals = consteig::eigenvalues(Ad); // Matrix<Complex, N, 1>
317
318 // p[0..k] holds the monic polynomial of degree k after k iterations.
319 Complex p[N + 1u]{};
320 p[0] = Complex{static_cast<T>(1), static_cast<T>(0)};
321
322 for (consteig::Size k = 0; k < N; ++k)
323 {
324 const Complex lam = evals(k, 0);
325 // Multiply degree-k poly by (z - lam), working high-to-low in-place.
326 p[k + 1u] = Complex{static_cast<T>(0), static_cast<T>(0)} - lam * p[k];
327 for (consteig::Size i = k; i > 0u; --i)
328 {
329 p[i] = p[i] - lam * p[i - 1u];
330 }
331 // p[0] is unchanged (stays 1)
332 }
333
334 for (consteig::Size i = 0; i <= N; ++i)
335 {
336 coeffs[i] = p[i].real;
337 }
338}
339
340// Markov numerator
341
342// Computes numerator polynomial b from Markov parameters and denominator a.
343// h[0] = D
344// h[k] = C * A^{k-1} * B for k = 1..N
345// b[k] = sum_{j=0}^{k} a[j] * h[k-j]
346template <typename T, consteig::Size N>
347constexpr void markov_numerator(const StateSpace<T, N> &sys_d,
348 const T (&a_coeffs)[N + 1u], T (&b)[N + 1u])
349{
350 const auto &Ad = sys_d.A;
351 const auto &Bd = sys_d.B;
352 const auto &Cd = sys_d.C;
353
354 // Compute Markov parameters h[0..N]
355 T h[N + 1u]{};
356 h[0] = sys_d.D;
357
358 // A_pow = A^{k-1}, starting with A^0 = I
359 consteig::Matrix<T, N, N> A_pow = consteig::eye<T, N>();
360
361 for (consteig::Size k = 1u; k <= N; ++k)
362 {
363 // h[k] = C * A_pow * B (scalar)
364 T val = static_cast<T>(0);
365 for (consteig::Size c = 0; c < N; ++c)
366 {
367 // (A_pow * B)[c] = sum_col A_pow(c,col)*B(col,0)
368 T AB_c = static_cast<T>(0);
369 for (consteig::Size col = 0; col < N; ++col)
370 {
371 AB_c += A_pow(c, col) * Bd(col, 0);
372 }
373 val += Cd(0, c) * AB_c;
374 }
375 h[k] = val;
376
377 // Advance: A_pow = A_pow * Ad
378 A_pow = A_pow * Ad;
379 }
380
381 // Convolve: b[k] = sum_{j=0}^{k} a[j] * h[k-j]
382 for (consteig::Size k = 0; k <= N; ++k)
383 {
384 T sum = static_cast<T>(0);
385 for (consteig::Size jj = 0; jj <= k; ++jj)
386 {
387 sum += a_coeffs[jj] * h[k - jj];
388 }
389 b[k] = sum;
390 }
391}
392
393// ss_to_tf
394
395// Full pipeline: discrete state-space -> (b, a) transfer function.
396template <typename T, consteig::Size N>
397constexpr TransferFunction<T, N + 1u, N + 1u> ss_to_tf(
398 const StateSpace<T, N> &sys_d)
399{
400 TransferFunction<T, N + 1u, N + 1u> tf{};
401 char_poly(sys_d.A, tf.a);
402 markov_numerator(sys_d, tf.a, tf.b);
403 return tf;
404}
405
406// tf_to_ss
407
408// Build controllable canonical form continuous-time state-space from
409// analog transfer function coefficients in descending power order:
410//
411// H(s) = (b[0]*s^N + b[1]*s^{N-1} + ... + b[N])
412// / (a[0]*s^N + a[1]*s^{N-1} + ... + a[N])
413//
414// Handles proper (deg b = deg a) and strictly proper (b[0]=0) cases.
415// a[0] must be non-zero; the denominator is normalized to monic form
416// internally.
417//
418// Resulting state-space (controllable canonical form):
419// A: super-diagonal = 1; last row = [-a_N, -a_{N-1}, ..., -a_1] / a_0
420// B: [0, ..., 0, 1]^T
421// C: [e_N, e_{N-1}, ..., e_1] where e_k = b_k/a_0 - D*(a_k/a_0)
422// D: b[0] / a[0]
423template <typename T, consteig::Size N>
424constexpr StateSpace<T, N> tf_to_ss(const T (&b)[N + 1u], const T (&a)[N + 1u])
425{
426 StateSpace<T, N> sys{};
427
428 const T inv_a0 = static_cast<T>(1) / a[0];
429
430 sys.D = b[0] * inv_a0;
431
432 // Numerator residual: e[k] = b[k]/a[0] - D*(a[k]/a[0]) for k=1..N
433 T e[N + 1u]{};
434 for (consteig::Size k = 1u; k <= N; ++k)
435 {
436 e[k] = b[k] * inv_a0 - sys.D * (a[k] * inv_a0);
437 }
438
439 // A: super-diagonal
440 for (consteig::Size row = 0; row < N - 1u; ++row)
441 {
442 sys.A(row, row + 1u) = static_cast<T>(1);
443 }
444
445 // A: last row = -a[N-k]/a[0] for k=0..N-1
446 for (consteig::Size k = 0; k < N; ++k)
447 {
448 sys.A(N - 1u, k) = -(a[N - k] * inv_a0);
449 }
450
451 // B: last entry = 1
452 sys.B(N - 1u, 0) = static_cast<T>(1);
453
454 // C: C[0][k] = e[N-k]
455 for (consteig::Size k = 0; k < N; ++k)
456 {
457 sys.C(0, k) = e[N - k];
458 }
459
460 return sys;
461}
462
463// Matched-Z discretization (TF entry point)
464
465// Shared kernel: given analog poles, finite zeros, zero count, continuous gain,
466// and sample period, maps to z-domain, pads missing zeros at z=-1, matches
467// gain, and assembles the discrete TF.
468template <typename T, consteig::Size N>
469constexpr TransferFunction<T, N + 1u, N + 1u> matched_z_assemble(
470 const consteig::Complex<T> (&poles)[N],
471 const consteig::Complex<T> (&zeros)[N], consteig::Size nz, T k_c, T Ts)
472{
473 using Complex = consteig::Complex<T>;
474
475 // Step 1: map poles to z-domain; build monic denominator polynomial.
476 Complex p_d_vals[N]{};
477 Complex pole_poly[N + 1u]{};
478 pole_poly[0] = Complex{static_cast<T>(1), static_cast<T>(0)};
479 for (consteig::Size k = 0; k < N; ++k)
480 {
481 p_d_vals[k] = consteig::exp(poles[k] * Complex{Ts, static_cast<T>(0)});
482 const Complex zk = p_d_vals[k];
483 pole_poly[k + 1u] =
484 Complex{static_cast<T>(0), static_cast<T>(0)} - zk * pole_poly[k];
485 for (consteig::Size i = k; i > 0u; --i)
486 {
487 pole_poly[i] = pole_poly[i] - zk * pole_poly[i - 1u];
488 }
489 }
490
491 // Step 2: map finite zeros to z-domain; build monic numerator polynomial.
492 Complex z_d_finite[N]{};
493 Complex zero_poly[N + 1u]{};
494 zero_poly[0] = Complex{static_cast<T>(1), static_cast<T>(0)};
495 for (consteig::Size k = 0; k < nz; ++k)
496 {
497 z_d_finite[k] =
498 consteig::exp(zeros[k] * Complex{Ts, static_cast<T>(0)});
499 const Complex zk = z_d_finite[k];
500 zero_poly[k + 1u] =
501 Complex{static_cast<T>(0), static_cast<T>(0)} - zk * zero_poly[k];
502 for (consteig::Size i = k; i > 0u; --i)
503 {
504 zero_poly[i] = zero_poly[i] - zk * zero_poly[i - 1u];
505 }
506 }
507
508 // Step 3: pad with zeros at z = -1 to reach numerator degree N-1.
509 const consteig::Size n_extra = (nz + 1u < N) ? (N - nz - 1u) : 0u;
510 for (consteig::Size e = 0; e < n_extra; ++e)
511 {
512 const consteig::Size cur_deg = nz + e;
513 zero_poly[cur_deg + 1u] = zero_poly[cur_deg + 1u] + zero_poly[cur_deg];
514 for (consteig::Size i = cur_deg; i > 0u; --i)
515 {
516 zero_poly[i] = zero_poly[i] + zero_poly[i - 1u];
517 }
518 }
519 const consteig::Size num_deg = nz + n_extra;
520
521 // Step 4: find matching frequency w_c (avoid collision with poles/zeros).
522 const T tol = gcem::sqrt(consteig::epsilon<T>());
523 T w_c = static_cast<T>(0);
524 for (consteig::Size attempt = 0; attempt < 1000u; ++attempt)
525 {
526 bool collision = false;
527 for (consteig::Size i = 0; i < N && !collision; ++i)
528 {
529 const T dr = w_c - poles[i].real;
530 const T di = poles[i].imag;
531 if (dr * dr + di * di < tol * tol)
532 {
533 collision = true;
534 }
535 }
536 for (consteig::Size i = 0; i < nz && !collision; ++i)
537 {
538 const T dr = w_c - zeros[i].real;
539 const T di = zeros[i].imag;
540 if (dr * dr + di * di < tol * tol)
541 {
542 collision = true;
543 }
544 }
545 if (!collision)
546 {
547 break;
548 }
549 w_c += static_cast<T>(0.1) / Ts;
550 }
551
552 // Step 5: compute discrete gain k_d matching H_d(w_d) = H_c(w_c).
553 const Complex w_c_cx{w_c, static_cast<T>(0)};
554 const Complex w_d_cx =
555 consteig::exp(w_c_cx * Complex{Ts, static_cast<T>(0)});
556
557 Complex num_c_cx{static_cast<T>(1), static_cast<T>(0)};
558 for (consteig::Size i = 0; i < nz; ++i)
559 {
560 num_c_cx = num_c_cx * (w_c_cx - zeros[i]);
561 }
562
563 Complex den_c_cx{static_cast<T>(1), static_cast<T>(0)};
564 for (consteig::Size i = 0; i < N; ++i)
565 {
566 den_c_cx = den_c_cx * (w_c_cx - poles[i]);
567 }
568
569 Complex num_d_cx{static_cast<T>(1), static_cast<T>(0)};
570 for (consteig::Size i = 0; i < N; ++i)
571 {
572 num_d_cx = num_d_cx * (w_d_cx - p_d_vals[i]);
573 }
574
575 Complex den_d_cx{static_cast<T>(1), static_cast<T>(0)};
576 for (consteig::Size i = 0; i < nz; ++i)
577 {
578 den_d_cx = den_d_cx * (w_d_cx - z_d_finite[i]);
579 }
580 for (consteig::Size e = 0; e < n_extra; ++e)
581 {
582 den_d_cx = den_d_cx *
583 (w_d_cx - Complex{static_cast<T>(-1), static_cast<T>(0)});
584 }
585
586 const Complex gain_num =
587 Complex{k_c, static_cast<T>(0)} * num_c_cx * num_d_cx;
588 const Complex gain_den = den_c_cx * den_d_cx;
589 const T gain_den_sq =
590 gain_den.real * gain_den.real + gain_den.imag * gain_den.imag;
591 const T k_d =
592 (gain_num.real * gain_den.real + gain_num.imag * gain_den.imag) /
593 gain_den_sq;
594
595 // Step 6: assemble output TF.
596 TransferFunction<T, N + 1u, N + 1u> tf{};
597 for (consteig::Size i = 0; i <= N; ++i)
598 {
599 tf.a[i] = pole_poly[i].real;
600 }
601 const consteig::Size pad = N - num_deg;
602 for (consteig::Size i = 0; i <= num_deg; ++i)
603 {
604 tf.b[pad + i] = k_d * zero_poly[i].real;
605 }
606
607 return tf;
608}
609
610// Full matched-Z from analog transfer function coefficients.
611// Maps each finite analog zero via z = exp(s*Ts), pads with zeros at z = -1
612// for strictly proper systems, and matches gain at a test frequency w_c.
613// Reference: Octave control pkg @tf/__c2d__.m, lines 32-66.
614// Steps 1-3: extract poles and zeros from polynomial coefficients via companion
615// matrix eigenvalues, then delegate to matched_z_assemble.
616template <typename T, consteig::Size N>
617constexpr TransferFunction<T, N + 1u, N + 1u> matched_z_discretize_tf(
618 const T (&b_c)[N + 1u], const T (&a_c)[N + 1u], T Ts, MatchedZ /*tag*/)
619{
620 using Complex = consteig::Complex<T>;
621
622 // Step 1: count leading zeros in b_c; derive nz and k_c.
623 consteig::Size d_b = 0;
624 while (d_b <= N && b_c[d_b] == static_cast<T>(0))
625 {
626 ++d_b;
627 }
628 const consteig::Size nz = (d_b > N) ? 0u : N - d_b;
629 const T k_c = (d_b > N) ? static_cast<T>(0) : b_c[d_b] / a_c[0];
630
631 // Step 2: find analog poles (roots of a_c) via companion matrix.
632 // consteig exposes eigenvalues, not polynomial roots; a companion matrix
633 // has eigenvalues equal to the polynomial roots by construction.
634 consteig::Matrix<T, N, N> A_pole{};
635 for (consteig::Size i = 0; i < N - 1u; ++i)
636 {
637 A_pole(i + 1u, i) = static_cast<T>(1);
638 }
639 for (consteig::Size i = 0; i < N; ++i)
640 {
641 A_pole(i, N - 1u) = -(a_c[N - i] / a_c[0]);
642 }
643 const auto p_c_evals = consteig::eigenvalues(A_pole);
644 Complex poles[N]{};
645 for (consteig::Size i = 0; i < N; ++i)
646 {
647 poles[i] = p_c_evals(i, 0);
648 }
649
650 // Step 3: find analog zeros (roots of b_c) via companion matrix.
651 // Embedded in NxN so consteig::eigenvalues can be called with a fixed
652 // size; the top-left nz x nz block holds the actual companion, the
653 // remaining entries are 0, producing d_b spurious eigenvalues at the
654 // origin that are discarded afterward.
655 Complex zeros[N]{};
656 if (nz > 0u)
657 {
658 consteig::Matrix<T, N, N> A_zero{};
659 for (consteig::Size i = 0; i + 1u < nz; ++i)
660 {
661 A_zero(i + 1u, i) = static_cast<T>(1);
662 }
663 for (consteig::Size i = 0; i < nz; ++i)
664 {
665 A_zero(i, nz - 1u) = -(b_c[d_b + (nz - i)] / b_c[d_b]);
666 }
667 const auto z_c_evals = consteig::eigenvalues(A_zero);
668
669 // Mark d_b spurious eigenvalues (smallest magnitude = from zero block).
670 bool spurious[N]{};
671 for (consteig::Size m = 0; m < d_b; ++m)
672 {
673 consteig::Size min_idx = 0;
674 T min_mag_sq = static_cast<T>(-1);
675 for (consteig::Size i = 0; i < N; ++i)
676 {
677 if (!spurious[i])
678 {
679 const T mag_sq =
680 z_c_evals(i, 0).real * z_c_evals(i, 0).real +
681 z_c_evals(i, 0).imag * z_c_evals(i, 0).imag;
682 if (min_mag_sq < static_cast<T>(0) || mag_sq < min_mag_sq)
683 {
684 min_mag_sq = mag_sq;
685 min_idx = i;
686 }
687 }
688 }
689 spurious[min_idx] = true;
690 }
691
692 consteig::Size nz_cnt = 0u;
693 for (consteig::Size k = 0; k < N; ++k)
694 {
695 if (!spurious[k])
696 {
697 zeros[nz_cnt++] = z_c_evals(k, 0);
698 }
699 }
700 }
701
702 return matched_z_assemble<T, N>(poles, zeros, nz, k_c, Ts);
703}
704
705// Matched-Z discretization from a FactoredTF: poles and zeros are known
706// analytically, so companion-matrix eigendecompositions are not needed.
707template <typename T, consteig::Size N>
708constexpr TransferFunction<T, N + 1u, N + 1u> matched_z_discretize_factored(
709 const FactoredTF<T, N> &factored_tf, T Ts)
710{
711 return matched_z_assemble<T, N>(factored_tf.poles, factored_tf.zeros,
712 factored_tf.nz, factored_tf.gain, Ts);
713}
714
715// Tustin (bilinear) discretization
716//
717// Parameterized by alpha = 2/Ts (standard) or wc/tan(wc*Ts/2) (prewarped).
718//
719// M = I - (1/alpha)*Ac
720// P = I + (1/alpha)*Ac
721// Ad = P * M^{-1} (right-solve: M^T * Ad^T = P^T)
722// Bd = (1/alpha) * (Ad + I) * Bc
723// Cd = Cc * M^{-1} (right-solve: M^T * Cd^T = Cc^T)
724// Dd = Dc + (1/alpha) * Cd * Bc
725//
726// M is LU-factorized once and reused for both Ad and Cd.
727// See https://dsp.stackexchange.com/questions/45042
728// Returns StateSpace (not TransferFunction) to preserve access to the discrete
729// matrices for callers that need them (e.g. state estimation, observer design).
730template <typename T, consteig::Size N>
731constexpr StateSpace<T, N> tustin_discretize_impl(const StateSpace<T, N> &sys_c,
732 T alpha)
733{
734 const auto &Ac = sys_c.A;
735 const auto &Bc = sys_c.B;
736 const auto &Cc = sys_c.C;
737
738 const T inv_alpha = static_cast<T>(1) / alpha;
739
740 // M = I - (1/alpha)*Ac, P = I + (1/alpha)*Ac
741 const consteig::Matrix<T, N, N> M = consteig::eye<T, N>() - inv_alpha * Ac;
742 const consteig::Matrix<T, N, N> P = consteig::eye<T, N>() + inv_alpha * Ac;
743
744 const auto lu_M{consteig::lu(M)};
745
746 // M^{-1} column by column via LU solve (lu_solve only accepts a single rhs)
747 consteig::Matrix<T, N, N> M_inv{};
748 for (consteig::Size col = 0; col < N; ++col)
749 {
750 consteig::Matrix<T, N, 1> e_col{};
751 e_col(col, 0) = static_cast<T>(1);
752 const auto col_vec{consteig::lu_solve(lu_M, e_col)};
753 for (consteig::Size row = 0; row < N; ++row)
754 {
755 M_inv(row, col) = col_vec(row, 0);
756 }
757 }
758
759 // Ad = P * M^{-1}
760 const consteig::Matrix<T, N, N> Ad{P * M_inv};
761
762 // Bd = (1/alpha) * (Ad + I) * Bc
763 const consteig::Matrix<T, N, 1> Bd{inv_alpha *
764 (Ad + consteig::eye<T, N>()) * Bc};
765
766 // Cd = Cc * M^{-1}
767 const consteig::Matrix<T, 1, N> Cd{Cc * M_inv};
768
769 // Dd = Dc + (1/alpha) * Cd * Bc
770 const T Dd{sys_c.D + inv_alpha * (Cd * Bc)(0, 0)};
771
772 StateSpace<T, N> sys_d{};
773 sys_d.A = Ad;
774 sys_d.B = Bd;
775 sys_d.C = Cd;
776 sys_d.D = Dd;
777 return sys_d;
778}
779
780template <typename T, consteig::Size N>
781constexpr StateSpace<T, N> tustin_discretize(const StateSpace<T, N> &sys_c,
782 T Ts, TustinNW /*tag*/)
783{
784 return tustin_discretize_impl(sys_c, static_cast<T>(2) / Ts);
785}
786
787template <typename T, consteig::Size N>
788constexpr StateSpace<T, N> tustin_discretize(const StateSpace<T, N> &sys_c,
789 T Ts, TustinPWData<T> tag)
790{
791 const T alpha =
792 tag.warp_omega / gcem::tan(tag.warp_omega * Ts / static_cast<T>(2));
793 return tustin_discretize_impl(sys_c, alpha);
794}
795
796// Backward-compatible wrapper: recovers (b_c, a_c) from SS and delegates.
797template <typename T, consteig::Size N>
798constexpr TransferFunction<T, N + 1u, N + 1u> matched_z_discretize(
799 const StateSpace<T, N> &sys_c, T Ts, MatchedZ /*tag*/)
800{
801 T a_c[N + 1u]{};
802 char_poly(sys_c.A, a_c);
803 T b_c[N + 1u]{};
804 markov_numerator(sys_c, a_c, b_c);
805 return matched_z_discretize_tf<T, N>(b_c, a_c, Ts, MatchedZ{});
806}
807
808// analog_to_digital (state-space overloads)
809
810template <typename T, consteig::Size N>
811constexpr TransferFunction<T, N + 1u, N + 1u> analog_to_digital(
812 const StateSpace<T, N> &sys_c, T Ts, ZOH)
813{
814 return ss_to_tf(zoh_discretize(sys_c, Ts, ZOH{}));
815}
816
817template <typename T, consteig::Size N>
818constexpr TransferFunction<T, N + 1u, N + 1u> analog_to_digital(
819 const StateSpace<T, N> &sys_c, T Ts, MatchedZ)
820{
821 return matched_z_discretize(sys_c, Ts, MatchedZ{});
822}
823
824template <typename T, consteig::Size N>
825constexpr TransferFunction<T, N + 1u, N + 1u> analog_to_digital(
826 const StateSpace<T, N> &sys_c, T Ts, TustinNW tag)
827{
828 return ss_to_tf(tustin_discretize(sys_c, Ts, tag));
829}
830
831template <typename T, consteig::Size N>
832constexpr TransferFunction<T, N + 1u, N + 1u> analog_to_digital(
833 const StateSpace<T, N> &sys_c, T Ts, TustinPWData<T> tag)
834{
835 return ss_to_tf(tustin_discretize(sys_c, Ts, tag));
836}
837
838// discretize_with_factored: tag-dispatched TF discretization using FactoredTF.
839// ZOH and MatchedZ use the factored path; Tustin falls back to the polynomial
840// path.
841
842template <typename T, consteig::Size N>
843constexpr TransferFunction<T, N + 1u, N + 1u> discretize_with_factored(
844 const T (&b_c)[N + 1u], const T (&a_c)[N + 1u],
845 const FactoredTF<T, N> &factored_tf, T Ts, ZOH)
846{
847 return ss_to_tf(
848 zoh_discretize_with_evals(tf_to_ss<T, N>(b_c, a_c), Ts, factored_tf));
849}
850
851template <typename T, consteig::Size N>
852constexpr TransferFunction<T, N + 1u, N + 1u> discretize_with_factored(
853 const T (& /*b_c*/)[N + 1u], const T (& /*a_c*/)[N + 1u],
854 const FactoredTF<T, N> &factored_tf, T Ts, MatchedZ)
855{
856 return matched_z_discretize_factored<T, N>(factored_tf, Ts);
857}
858
859template <typename T, consteig::Size N, typename M>
860constexpr TransferFunction<T, N + 1u, N + 1u> discretize_with_factored(
861 const T (&b_c)[N + 1u], const T (&a_c)[N + 1u],
862 const FactoredTF<T, N> & /*factored_tf*/, T Ts, M method_tag)
863{
864 return analog_to_digital<T, N>(b_c, a_c, Ts, method_tag);
865}
866
867// analog_to_digital (TF overloads)
868
869template <typename T, consteig::Size N>
870constexpr TransferFunction<T, N + 1u, N + 1u> analog_to_digital(
871 const T (&b_c)[N + 1u], const T (&a_c)[N + 1u], T Ts, ZOH)
872{
873 return ss_to_tf(zoh_discretize(tf_to_ss<T, N>(b_c, a_c), Ts, ZOH{}));
874}
875
876template <typename T, consteig::Size N>
877constexpr TransferFunction<T, N + 1u, N + 1u> analog_to_digital(
878 const T (&b_c)[N + 1u], const T (&a_c)[N + 1u], T Ts, MatchedZ)
879{
880 return matched_z_discretize_tf<T, N>(b_c, a_c, Ts, MatchedZ{});
881}
882
883template <typename T, consteig::Size N>
884constexpr TransferFunction<T, N + 1u, N + 1u> analog_to_digital(
885 const T (&b_c)[N + 1u], const T (&a_c)[N + 1u], T Ts, TustinNW tag)
886{
887 return ss_to_tf(tustin_discretize(tf_to_ss<T, N>(b_c, a_c), Ts, tag));
888}
889
890template <typename T, consteig::Size N>
891constexpr TransferFunction<T, N + 1u, N + 1u> analog_to_digital(
892 const T (&b_c)[N + 1u], const T (&a_c)[N + 1u], T Ts, TustinPWData<T> tag)
893{
894 return ss_to_tf(tustin_discretize(tf_to_ss<T, N>(b_c, a_c), Ts, tag));
895}
896
897} // namespace constfilt
898
899#endif // CONSTFILT_DISCRETIZE_HPP