fix(signal): refuse filter designs whose coefficients overflow

Assisted-by: GLM 5.3 Flash
This commit is contained in:
2026-09-28 21:36:09 +02:00
parent e9f268a461
commit 045ef28d24
5 changed files with 91 additions and 1 deletions
+26
View File
@@ -360,6 +360,9 @@ func butterworth(order int, fs, cutoff float64, highpass bool) (b, a []float64,
}
}
k := polyEvalAtMinusOne(a) / math.Pow(2, float64(order))
if err := designCoefficientsFinite(name, order, k, b, a); err != nil {
return nil, nil, err
}
for i := range b {
b[i] *= k
}
@@ -367,12 +370,35 @@ func butterworth(order int, fs, cutoff float64, highpass bool) (b, a []float64,
}
b = binomialCoeffs(order)
k := polyEvalAtOne(a) / math.Pow(2, float64(order))
if err := designCoefficientsFinite(name, order, k, b, a); err != nil {
return nil, nil, err
}
for i := range b {
b[i] *= k
}
return b, a, nil
}
// designCoefficientsFinite refuses a design whose arithmetic left the
// float64 range. Past an order of about a thousand the gain divides by
// an infinite 2^order and the binomial numerator overflows with it,
// answers that would otherwise ship as a numerator of zeros or NaN
// presented as a filter: the gain must be finite and non-zero, and
// every coefficient of both polynomials finite.
func designCoefficientsFinite(name string, order int, gain float64, b, a []float64) error {
if math.IsNaN(gain) || math.IsInf(gain, 0) || gain == 0 {
return base.Errf("%s: order %d overflows the coefficient arithmetic; use a lower order", name, order)
}
for _, poly := range [2][]float64{b, a} {
for _, v := range poly {
if math.IsNaN(v) || math.IsInf(v, 0) {
return base.Errf("%s: order %d overflows the coefficient arithmetic; use a lower order", name, order)
}
}
}
return nil
}
// mulPolyReal multiplies two real polynomials in u = z^{-1} (index m
// is the coefficient of u^m).
func mulPolyReal(p, q []float64) []float64 {
+13
View File
@@ -286,9 +286,22 @@ func design(sh shape, proto prototype, w1, w2 float64) (b, a []float64, err erro
// 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
}
+46
View File
@@ -130,6 +130,52 @@ func TestFilterDesignsAgainstReferenceValues(t *testing.T) {
}
}
// TestExtremeFilterOrders pins the designs against the extreme-but-valid
// order corner: the entry gates ask only for order ≥ 1, and past a few
// hundred the coefficient arithmetic leaves the float64 range (the
// Butterworth gain divides by 2^order, the binomial numerator and the
// assembled polynomials overflow with it). An order the arithmetic
// cannot carry must come back as an error, never as a numerator of
// zeros or NaN presented as a filter; an order it does carry must
// answer with finite coefficients and a response whose numerator did
// not vanish.
func TestExtremeFilterOrders(t *testing.T) {
const fs = 1000.0
designs := []struct {
name string
run func() ([]float64, []float64, error)
probe float64
}{
{"butterworth-low-1024", func() ([]float64, []float64, error) { return ButterworthLowPass(1024, fs, 100) }, 10},
{"butterworth-high-1024", func() ([]float64, []float64, error) { return ButterworthHighPass(1024, fs, 100) }, 490},
{"butterworth-band-pass-513", func() ([]float64, []float64, error) { return ButterworthBandPass(513, fs, 100, 300) }, 200},
{"chebyshev1-low-1024", func() ([]float64, []float64, error) { return ChebyshevLowPass(1024, fs, 100, 1) }, 10},
{"chebyshev2-low-1024", func() ([]float64, []float64, error) { return InverseChebyshevLowPass(1024, fs, 100, 40) }, 10},
{"cauer-low-512", func() ([]float64, []float64, error) { return CauerLowPass(512, fs, 100, 1, 60) }, 10},
}
for _, d := range designs {
b, a, err := d.run()
if err != nil {
// A refusal naming the overflow is the honest answer.
continue
}
for _, v := range b {
if math.IsNaN(v) || math.IsInf(v, 0) {
t.Fatalf("%s: the numerator holds the non-finite coefficient %g", d.name, v)
}
}
for _, v := range a {
if math.IsNaN(v) || math.IsInf(v, 0) {
t.Fatalf("%s: the denominator holds the non-finite coefficient %g", d.name, v)
}
}
g := designResponse(b, a, d.probe, fs)
if math.IsNaN(g) || g == 0 {
t.Fatalf("%s: the response at %g Hz is %.12g, the arithmetic lost the design", d.name, d.probe, g)
}
}
}
// TestFilterDesignStability checks that every design's poles sit
// inside the unit circle, the property direct-form filtering lives
// and dies by.