// Copyright (c) 2026 Petr Balvín (https://petrbalvin.org) // SPDX-License-Identifier: MIT package signal import ( "math" "math/cmplx" "slices" "sourcedock.dev/petrbalvin/tensor/internal/base" "sourcedock.dev/petrbalvin/tensor/internal/core" ) // IIR designs beyond the Butterworth pair: the Chebyshev equiripple // family, the inverse Chebyshev, the elliptic (Cauer) designs, and // the band shapes for every prototype. Each design walks the same // road: an analog low-pass prototype at unit passband edge, the // shape transformation of its pole-zero set with both band edges // prewarped to the bilinear axis, and the bilinear mapping of each // pole and zero. The mapping is exact, so the prototype's passband // peak and edge attenuations land on the digital side at the mapped // frequencies; that is what the tests pin. // // The coefficients come back in the same u = z⁻¹ convention as the // Butterworth pair, with the denominator leading a one. Direct-form // filtering loses digits as the order climbs, so past roughly order // eight a design should be split into second-order sections by the // caller; where that line sits is deliberately left to taste. // prototype is one analog low-pass at unit passband edge. Poles and // finite zeroes carry exact conjugate symmetry; the zeroes a low-pass // holds at infinity are counted, not listed. type prototype struct { poles []complex128 zeros []complex128 zerosAtInfinity int gain float64 } // chebyshev1 returns the type I prototype: the Butterworth circle // squashed by the ripple's hyperbolic factor, so the passband swings // between one and 1/sqrt(1+eps²), the peak normalised to one. The // edge sits where the gain first drops to -rippleDB. func chebyshev1(order int, rippleDB float64) prototype { eps := math.Sqrt(math.Pow(10, rippleDB/10) - 1) mu := math.Asinh(1/eps) / float64(order) poles := make([]complex128, 0, order) for k := range order { theta := math.Pi * float64(2*k+1) / float64(2*order) // One Sincos serves the pole's both factors: the pair is the // one the separate Sin and Cos calls produced, so the pole // keeps its bits. sinT, cosT := math.Sincos(theta) if math.Abs(cosT) < 1e-15 { // The odd order's real pole, built exactly. poles = append(poles, complex(-math.Sinh(mu), 0)) continue } if cosT < 0 { continue // the mirror angle's conjugate, already stored } p := complex(-math.Sinh(mu)*sinT, math.Cosh(mu)*cosT) poles = append(poles, p, cmplx.Conj(p)) } gain := 1.0 if order%2 == 0 { gain = 1 / math.Sqrt(1+eps*eps) } return prototype{poles: poles, zerosAtInfinity: order, gain: gain} } // chebyshev2 returns the inverse Chebyshev: zeroes on the imaginary // axis at the reciprocals of the type I ripple points, poles at the // reciprocals of type I poles built with the stopband's factor, so // the stopband floor is exactly the demanded attenuation. The root // lattice runs over the integers of one parity between −(n−1) and // n−1: for odd orders it passes through zero, holding the real pole // there, and the zero at infinity keeps m = 0 out of the zero list. func chebyshev2(order int, stopbandDB float64) prototype { de := 1 / math.Sqrt(math.Pow(10, stopbandDB/10)-1) mu := math.Asinh(1/de) / float64(order) poles := make([]complex128, 0, order) zeros := make([]complex128, 0, order) for m := -order + 1; m <= order-1; m += 2 { theta := math.Pi * float64(m) / float64(2*order) if m >= 0 { p := -1 / cmplx.Sinh(complex(mu, theta)) if m == 0 { poles = append(poles, p) } else { poles = append(poles, p, cmplx.Conj(p)) } if m > 0 { z := complex(0, 1/math.Sin(theta)) zeros = append(zeros, z, cmplx.Conj(z)) } } } return prototype{poles: poles, zeros: zeros, zerosAtInfinity: order - len(zeros), gain: 1} } // cauer returns the elliptic prototype. The zeroes are cd of the // quarter-period fractions, read through the library's Jacobi // functions; the poles ride the addition theorem through the // amplitude v0 solved from the stopband's discrimination; and the // degree equation closes through the nome, so ripple and attenuation // both land on spec at the first transition edge. func cauer(order int, rippleDB, stopbandDB float64) prototype { epsSq := math.Pow(10, rippleDB/10) - 1 eps := math.Sqrt(epsSq) m1 := epsSq / (math.Pow(10, stopbandDB/10) - 1) m := ellipdeg(order, m1) capk := core.EllipticKScalar(m) // Amplitudes along the quarter period: u_j = j·K/n for odd j when // the order is even, even j when it is odd, where u = 0 holds the // zero at infinity. zeroes := make([]complex128, 0, order) amplitudes := make([][3]float64, 0, order) for j := 1 - order%2; j < order; j += 2 { u := float64(j) * capk / float64(order) s := jacobiScalar(core.JacobiSN, u, m) c := jacobiScalar(core.JacobiCN, u, m) d := jacobiScalar(core.JacobiDN, u, m) if math.Abs(s) > 1e-12 { z := complex(0, 1/(math.Sqrt(m)*s)) zeroes = append(zeroes, z, cmplx.Conj(z)) } amplitudes = append(amplitudes, [3]float64{s, c, d}) } // v0: the amplitude solving sc(v0, 1−m1) = 1/ε on the // complementary parameter, through the identity sc(u, m) = // tan(am(u, m)): v0 = F(atan(1/ε), 1−m1), scaled by the nome // ratio the degree equation provides. The pole formula then reads // its Jacobi amplitudes at v0 on the prototype's own parameter. v0 := capk * core.EllipticFScalar(math.Atan(1/eps), 1-m1) / (float64(order) * core.EllipticKScalar(m1)) sv := jacobiScalar(core.JacobiSN, v0, 1-m) cv := jacobiScalar(core.JacobiCN, v0, 1-m) dv := jacobiScalar(core.JacobiDN, v0, 1-m) poles := make([]complex128, 0, order) for _, a := range amplitudes { s, c, d := a[0], a[1], a[2] p := -complex(c*d*sv*cv, s*dv) / (1 - complex((d*sv)*(d*sv), 0)) if math.Abs(imag(p)) < 1e-10 { poles = append(poles, complex(real(p), 0)) continue } poles = append(poles, p, cmplx.Conj(p)) } gain := 1.0 if order%2 == 0 { gain = 1 / math.Sqrt(1+epsSq) } return prototype{poles: poles, zeros: zeroes, zerosAtInfinity: order - len(zeroes), gain: gain} } // ellipdeg solves the degree equation n·K(m)/K'(m) = K(m1)/K'(m1) // for m through the nome q = exp(−π·K'/K), whose theta product is // accurate to double precision within the first eight powers. func ellipdeg(n int, m1 float64) float64 { k1 := core.EllipticKScalar(m1) k1p := core.EllipticKScalar(1 - m1) q := math.Pow(math.Exp(-math.Pi*k1p/k1), 1.0/float64(n)) num, den := 1.0, 1.0 for k := 1; k <= 7; k++ { num += math.Pow(q, float64(k*(k+1))) } for k := 1; k <= 8; k++ { den += 2 * math.Pow(q, float64(k*k)) } return 16 * q * math.Pow(num/den, 4) } // jacobiScalar reads one Jacobi function at one point through the // array implementation, which inverts the amplitude by bracketed // Newton against Carlson's incomplete integral. func jacobiScalar(f func(u *core.Array, m float64) (*core.Array, error), u, m float64) float64 { arr, err := core.FromFloats([]float64{u}, 1) if err != nil { return math.NaN() } out, err := f(arr, m) if err != nil { return math.NaN() } return out.FloatAt(0) } // mapEdge transforms one analog root of the unit-edge prototype into // the prewarped target band. Band roots come back as the pair of a // quadratic, the pairing surviving because the map sends conjugate // pairs to conjugate pairs. func mapEdge(s complex128, sh shape, w1, w2 float64) []complex128 { switch sh { case lowPass: return []complex128{s * complex(w1, 0)} case highPass: return []complex128{complex(w1, 0) / s} case bandPass: // Scale to the half bandwidth, then split about the centre: // the pair solves s² − BW·root·s + w0² = 0. half := s * complex((w2-w1)/2, 0) disc := cmplx.Sqrt(half*half - complex(w1*w2, 0)) return []complex128{half + disc, half - disc} default: // bandStop: invert to the half-bandwidth high-pass, then // split about the centre the same way. half := complex((w2-w1)/2, 0) / s disc := cmplx.Sqrt(half*half - complex(w1*w2, 0)) return []complex128{half + disc, half - disc} } } // shape picks the band the design passes. type shape int const ( lowPass shape = iota highPass bandPass bandStop ) // design runs the shared road from prototype to coefficients: shape // transformation of every root, bilinear mapping, assembly into real // u = z⁻¹ polynomials, and the gain taken from the prototype itself // at its DC, which every shape reaches through the mapping (the // low-pass at DC, the high-pass at Nyquist, the band pair at the // band centre and DC respectively). func design(sh shape, proto prototype, w1, w2 float64) (b, a []float64, err error) { const name = "filter design" // Denominator roots: every pole, shape-transformed and mapped. var poles []complex128 for _, p := range proto.poles { for _, s := range mapEdge(p, sh, w1, w2) { if real(s) > 1e-7*(1+cmplx.Abs(s)) { return nil, nil, base.Errf("%s: a pole escaped the left half-plane", name) } poles = append(poles, bilinear(s)) } } // Numerator roots: every finite zero's image, then the zeroes at // infinity: (1+u) factors for the low-pass, whose infinity maps // to u = −1; (1−u) pairs for the band-pass, whose infinity maps // to s = 0, u = 1; and one ±j·w0 conjugate pair per zero for the // band-stop. The infinity factors are digital already; only the // finite zeroes pass through the bilinear map. var zeros []complex128 for _, z := range proto.zeros { zeros = append(zeros, mapEdge(z, sh, w1, w2)...) } zeros = bilinearAll(zeros) zeros = append(zeros, infinityFactors(sh, proto.zerosAtInfinity, math.Sqrt(w1*w2))...) b, err = assembleRoots(zeros) if err != nil { return nil, nil, err } a, err = assembleRoots(poles) if err != nil { return nil, nil, err } // Gain: the prototype's gain field is the response the design // promises at its reference point (the passband peak, one or // 1/sqrt(1+eps²) by order parity), so the numerator scales until // the digital response at the mapped reference equals it. var uRef complex128 switch sh { case lowPass, bandStop: uRef = 1 case highPass: uRef = -1 default: // bandPass: the band centre, the geometric mean edge; the // conjugate side keeps the polynomial evaluation real. uRef = cmplx.Conj(bilinear(complex(0, math.Sqrt(w1*w2)))) } hd := polyEvalC(b, uRef) / polyEvalC(a, uRef) if hd == 0 { return nil, nil, base.Errf("%s: the reference point carries no gain", name) } // The magnitude is what the gain field promises; the phase at the // reference follows from the roots and is no business of the // scaling. scale := proto.gain / cmplx.Abs(hd) // An extreme order leaves the float64 range here as it does in the // Butterworth pair: an infinite or vanished scale, or a coefficient // past the range, is a refusal rather than a filter of zeros or NaN. if math.IsNaN(scale) || math.IsInf(scale, 0) || scale == 0 { return nil, nil, base.Errf("%s: the order overflows the coefficient arithmetic; use a lower order", name) } for i := range b { b[i] *= scale } for _, poly := range [2][]float64{b, a} { for _, v := range poly { if math.IsNaN(v) || math.IsInf(v, 0) { return nil, nil, base.Errf("%s: the order overflows the coefficient arithmetic; use a lower order", name) } } } return b, a, nil } // infinityFactors names the numerator roots the prototype's zeroes at // infinity turn into after the shape transformation: the low-pass // zeroes at s = ∞ land at u = −1; the high-pass zeroes at s = 0 land // at u = +1; the band-pass substitution squares its frequency, so // every infinity zero becomes a double zero at s = 0, two (1−u) // factors; and the band-stop turns every infinity zero into the // conjugate pair ±j·w0, the roots of s² + w0² = 0. func infinityFactors(sh shape, atInfinity int, w0 float64) []complex128 { switch sh { case lowPass: roots := make([]complex128, 0, atInfinity) for range atInfinity { roots = append(roots, -1) } return roots case bandPass: // The substitution's s = (1−u)/(1+u) leaves the numerator as // (1−u²)^N: half the roots at u = 1, half at u = −1. roots := make([]complex128, 0, 2*atInfinity) for range atInfinity { roots = append(roots, 1, -1) } return roots case bandStop: roots := make([]complex128, 0, 2*atInfinity) for range atInfinity { d := bilinear(complex(0, w0)) roots = append(roots, d, cmplx.Conj(d)) } return roots default: // highPass roots := make([]complex128, 0, atInfinity) for range atInfinity { roots = append(roots, 1) } return roots } } // assembleRoots factors digital roots into a real polynomial in u = // z⁻¹: conjugate pairs become the real quadratic // (1 − z·u)(1 − z̄·u) = 1 − 2Re(z)·u + |z|²·u², real roots the linear // factor (1 − z·u). The pairing matches each root against the // remaining roots' conjugates, so it does not depend on the order // the roots arrived in. func assembleRoots(roots []complex128) ([]float64, error) { const name = "filter design" poly := []float64{1} used := make([]bool, len(roots)) for i, r := range roots { if used[i] { continue } if math.Abs(imag(r)) < 1e-9 { used[i] = true poly = mulPolyReal(poly, []float64{1, -real(r)}) continue } // The nearest conjugate partner among the unused roots. partner := -1 best := math.Inf(1) for j := i + 1; j < len(roots); j++ { if used[j] { continue } if d := cmplx.Abs(roots[j] - cmplx.Conj(r)); d < best { best, partner = d, j } } if partner < 0 || best > 1e-6*cmplx.Abs(r) { return nil, base.Errf("%s: the roots lost their conjugate symmetry", name) } used[partner] = true poly = mulPolyReal(poly, []float64{1, -2 * real(r), real(r * cmplx.Conj(r))}) } return poly, nil } // bilinearAll maps a root list through the bilinear transform. func bilinearAll(roots []complex128) []complex128 { out := make([]complex128, len(roots)) for i, s := range roots { out[i] = bilinear(s) } return out } // bilinear maps an analog root to its digital image, the T = 2 // sampling the prewarp assumes. func bilinear(s complex128) complex128 { return (1 + s) / (1 - s) } // polyEvalC evaluates a u = z⁻¹ polynomial at one complex point. func polyEvalC(poly []float64, u complex128) complex128 { total := complex(0, 0) for _, p := range slices.Backward(poly) { total = total*u + complex(p, 0) } return total } // The public designs. Each validates its arguments, prewarps the // edges, and hands the shared road its prototype. // designArgs bundles the validated prewarped edges for one call. func designArgs(name string, order int, fs, edge1, edge2 float64, sh shape) (w1, w2 float64, err error) { if order < 1 { return 0, 0, base.Errf("%s: the order must be at least 1, got %d", name, order) } if !(fs > 0) || math.IsInf(fs, 0) { return 0, 0, base.Errf("%s: fs must be positive and finite, got %g", name, fs) } if !(edge1 > 0) || edge1 >= fs/2 { return 0, 0, base.Errf("%s: the edge must lie in (0, fs/2), got %g for fs %g", name, edge1, fs) } w1 = math.Tan(math.Pi * edge1 / fs) if sh == bandPass || sh == bandStop { if !(edge2 > edge1) || edge2 >= fs/2 { return 0, 0, base.Errf("%s: the band must span (edge1, edge2) inside (0, fs/2), got %g, %g for fs %g", name, edge1, edge2, fs) } w2 = math.Tan(math.Pi * edge2 / fs) } return w1, w2, nil } // ChebyshevLowPass designs an order-N type I Chebyshev low-pass at fs // hertz with its ripple in decibels: the passband oscillates between // 0 and -rippleDB, the edge is the last touch of -rippleDB, and the // stopband rolls off as fast as that budget allows. func ChebyshevLowPass(order int, fs, cutoff, rippleDB float64) (b, a []float64, err error) { const name = "ChebyshevLowPass" if rippleDB <= 0 { return nil, nil, base.Errf("%s: the ripple must be positive decibels, got %g", name, rippleDB) } w, _, err := designArgs(name, order, fs, cutoff, 0, lowPass) if err != nil { return nil, nil, err } return design(lowPass, chebyshev1(order, rippleDB), w, 0) } // ChebyshevHighPass is the type I mirror: the same equiripple // passband above the edge, rolling off below it. func ChebyshevHighPass(order int, fs, cutoff, rippleDB float64) (b, a []float64, err error) { const name = "ChebyshevHighPass" if rippleDB <= 0 { return nil, nil, base.Errf("%s: the ripple must be positive decibels, got %g", name, rippleDB) } w, _, err := designArgs(name, order, fs, cutoff, 0, highPass) if err != nil { return nil, nil, err } return design(highPass, chebyshev1(order, rippleDB), w, 0) } // InverseChebyshevLowPass designs the type II low-pass: a flat // passband through the edge, with the stopband bottoming out at // -stopbandDB and equiripple beyond it. func InverseChebyshevLowPass(order int, fs, cutoff, stopbandDB float64) (b, a []float64, err error) { const name = "InverseChebyshevLowPass" if stopbandDB <= 0 { return nil, nil, base.Errf("%s: the stopband attenuation must be positive decibels, got %g", name, stopbandDB) } w, _, err := designArgs(name, order, fs, cutoff, 0, lowPass) if err != nil { return nil, nil, err } return design(lowPass, chebyshev2(order, stopbandDB), w, 0) } // InverseChebyshevHighPass is the type II mirror above the edge. func InverseChebyshevHighPass(order int, fs, cutoff, stopbandDB float64) (b, a []float64, err error) { const name = "InverseChebyshevHighPass" if stopbandDB <= 0 { return nil, nil, base.Errf("%s: the stopband attenuation must be positive decibels, got %g", name, stopbandDB) } w, _, err := designArgs(name, order, fs, cutoff, 0, highPass) if err != nil { return nil, nil, err } return design(highPass, chebyshev2(order, stopbandDB), w, 0) } // CauerLowPass designs the elliptic low-pass: equiripple in the // passband within rippleDB and equiripple stopband not above // -stopbandDB, with the narrowest transition of any design at the // order. The zeroes sit in the stopband, finite and on the unit // circle after mapping. func CauerLowPass(order int, fs, cutoff, rippleDB, stopbandDB float64) (b, a []float64, err error) { const name = "CauerLowPass" if rippleDB <= 0 || stopbandDB <= rippleDB { return nil, nil, base.Errf("%s: the ripple must be positive and the attenuation larger, got %g and %g", name, rippleDB, stopbandDB) } w, _, err := designArgs(name, order, fs, cutoff, 0, lowPass) if err != nil { return nil, nil, err } return design(lowPass, cauer(order, rippleDB, stopbandDB), w, 0) } // CauerHighPass is the elliptic mirror above the edge. func CauerHighPass(order int, fs, cutoff, rippleDB, stopbandDB float64) (b, a []float64, err error) { const name = "CauerHighPass" if rippleDB <= 0 || stopbandDB <= rippleDB { return nil, nil, base.Errf("%s: the ripple must be positive and the attenuation larger, got %g and %g", name, rippleDB, stopbandDB) } w, _, err := designArgs(name, order, fs, cutoff, 0, highPass) if err != nil { return nil, nil, err } return design(highPass, cauer(order, rippleDB, stopbandDB), w, 0) } // ChebyshevBandPass designs the type I band-pass spanning edge1 to // edge2: the prototype's order doubles through the band move. func ChebyshevBandPass(order int, fs, edge1, edge2, rippleDB float64) (b, a []float64, err error) { const name = "ChebyshevBandPass" if rippleDB <= 0 { return nil, nil, base.Errf("%s: the ripple must be positive decibels, got %g", name, rippleDB) } w1, w2, err := designArgs(name, order, fs, edge1, edge2, bandPass) if err != nil { return nil, nil, err } return design(bandPass, chebyshev1(order, rippleDB), w1, w2) } // ChebyshevBandStop designs the type I band-stop. func ChebyshevBandStop(order int, fs, edge1, edge2, rippleDB float64) (b, a []float64, err error) { const name = "ChebyshevBandStop" if rippleDB <= 0 { return nil, nil, base.Errf("%s: the ripple must be positive decibels, got %g", name, rippleDB) } w1, w2, err := designArgs(name, order, fs, edge1, edge2, bandStop) if err != nil { return nil, nil, err } return design(bandStop, chebyshev1(order, rippleDB), w1, w2) } // InverseChebyshevBandPass designs the type II band-pass. func InverseChebyshevBandPass(order int, fs, edge1, edge2, stopbandDB float64) (b, a []float64, err error) { const name = "InverseChebyshevBandPass" if stopbandDB <= 0 { return nil, nil, base.Errf("%s: the stopband attenuation must be positive decibels, got %g", name, stopbandDB) } w1, w2, err := designArgs(name, order, fs, edge1, edge2, bandPass) if err != nil { return nil, nil, err } return design(bandPass, chebyshev2(order, stopbandDB), w1, w2) } // InverseChebyshevBandStop designs the type II band-stop. func InverseChebyshevBandStop(order int, fs, edge1, edge2, stopbandDB float64) (b, a []float64, err error) { const name = "InverseChebyshevBandStop" if stopbandDB <= 0 { return nil, nil, base.Errf("%s: the stopband attenuation must be positive decibels, got %g", name, stopbandDB) } w1, w2, err := designArgs(name, order, fs, edge1, edge2, bandStop) if err != nil { return nil, nil, err } return design(bandStop, chebyshev2(order, stopbandDB), w1, w2) } // CauerBandPass designs the elliptic band-pass. func CauerBandPass(order int, fs, edge1, edge2, rippleDB, stopbandDB float64) (b, a []float64, err error) { const name = "CauerBandPass" if rippleDB <= 0 || stopbandDB <= rippleDB { return nil, nil, base.Errf("%s: the ripple must be positive and the attenuation larger, got %g and %g", name, rippleDB, stopbandDB) } w1, w2, err := designArgs(name, order, fs, edge1, edge2, bandPass) if err != nil { return nil, nil, err } return design(bandPass, cauer(order, rippleDB, stopbandDB), w1, w2) } // CauerBandStop designs the elliptic band-stop. func CauerBandStop(order int, fs, edge1, edge2, rippleDB, stopbandDB float64) (b, a []float64, err error) { const name = "CauerBandStop" if rippleDB <= 0 || stopbandDB <= rippleDB { return nil, nil, base.Errf("%s: the ripple must be positive and the attenuation larger, got %g and %g", name, rippleDB, stopbandDB) } w1, w2, err := designArgs(name, order, fs, edge1, edge2, bandStop) if err != nil { return nil, nil, err } return design(bandStop, cauer(order, rippleDB, stopbandDB), w1, w2) } // ButterworthBandPass designs the maximally flat band-pass. func ButterworthBandPass(order int, fs, edge1, edge2 float64) (b, a []float64, err error) { const name = "ButterworthBandPass" w1, w2, err := designArgs(name, order, fs, edge1, edge2, bandPass) if err != nil { return nil, nil, err } return design(bandPass, butterworthPrototype(order), w1, w2) } // ButterworthBandStop designs the maximally flat band-stop. func ButterworthBandStop(order int, fs, edge1, edge2 float64) (b, a []float64, err error) { const name = "ButterworthBandStop" w1, w2, err := designArgs(name, order, fs, edge1, edge2, bandStop) if err != nil { return nil, nil, err } return design(bandStop, butterworthPrototype(order), w1, w2) } // butterworthPrototype rebuilds the maximally flat poles in the // prototype shape, so the band shapes share the same road; the // existing ButterworthLowPass and ButterworthHighPass keep their own // pinned implementations untouched. func butterworthPrototype(order int) prototype { poles := make([]complex128, 0, order) for k := range order { theta := math.Pi * float64(2*k+1) / float64(2*order) // One Sincos serves the pole's both factors, the same pair the // separate calls produced. sinT, cosT := math.Sincos(theta) if math.Abs(cosT) < 1e-15 { poles = append(poles, complex(-1, 0)) continue } if cosT < 0 { continue // the mirror angle's conjugate, already stored } p := complex(-sinT, cosT) poles = append(poles, p, cmplx.Conj(p)) } return prototype{poles: poles, zerosAtInfinity: order, gain: 1} }