Files
tensor/signal/filterdesign.go
T

636 lines
23 KiB
Go
Raw Normal View History

2026-09-03 10:00:00 +02:00
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (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)
for i := range b {
b[i] *= scale
}
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}
}