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
+3
View File
@@ -54,6 +54,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `WriteSVG` keeps extreme but finite data and axis ranges drawable: - `WriteSVG` keeps extreme but finite data and axis ranges drawable:
the padding, projection and tick arithmetic fall back to forms whose the padding, projection and tick arithmetic fall back to forms whose
terms stay in range, so the file never carries a NaN coordinate. terms stay in range, so the file never carries a NaN coordinate.
- Filter design refuses an order whose coefficient arithmetic
overflows the float64 range instead of shipping a numerator of
zeros or NaN.
## [1.0.0] - 2026-09-03 ## [1.0.0] - 2026-09-03
+3 -1
View File
@@ -1054,7 +1054,9 @@ Each design returns the direct-form coefficients `b` (numerator) and `a`
band edges prewarped to the bilinear axis and the answer exact at the mapped band edges prewarped to the bilinear axis and the answer exact at the mapped
frequencies. All of them refuse an order below 1, a non-positive or infinite `fs`, frequencies. All of them refuse an order below 1, a non-positive or infinite `fs`,
and an edge outside `(0, fs/2)`; the band forms additionally require and an edge outside `(0, fs/2)`; the band forms additionally require
`0 < edge1 < edge2 < fs/2`. `0 < edge1 < edge2 < fs/2`. An order whose coefficient arithmetic
overflows the float64 range is refused as well, never returned as a
numerator of zeros or NaN.
| Call | What it does | | Call | What it does |
|---|---| |---|---|
+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)) 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 { for i := range b {
b[i] *= k b[i] *= k
} }
@@ -367,12 +370,35 @@ func butterworth(order int, fs, cutoff float64, highpass bool) (b, a []float64,
} }
b = binomialCoeffs(order) b = binomialCoeffs(order)
k := polyEvalAtOne(a) / math.Pow(2, float64(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 { for i := range b {
b[i] *= k b[i] *= k
} }
return b, a, nil 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 // mulPolyReal multiplies two real polynomials in u = z^{-1} (index m
// is the coefficient of u^m). // is the coefficient of u^m).
func mulPolyReal(p, q []float64) []float64 { 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 // reference follows from the roots and is no business of the
// scaling. // scaling.
scale := proto.gain / cmplx.Abs(hd) 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 { for i := range b {
b[i] *= scale 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 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 // TestFilterDesignStability checks that every design's poles sit
// inside the unit circle, the property direct-form filtering lives // inside the unit circle, the property direct-form filtering lives
// and dies by. // and dies by.