Files
tensor/signal/filter.go
T
petrbalvin af4ee19703
Release / gates (push) Successful in 4m38s
Test / test (push) Successful in 5m16s
Release / release (push) Successful in 35s
feat: initial release
Assisted-by: GLM 5.3 Flash
2026-09-03 10:00:00 +02:00

516 lines
17 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
package signal
import (
"math"
"sync"
"sourcedock.dev/petrbalvin/tensor/internal/base"
"sourcedock.dev/petrbalvin/tensor/internal/core"
"sourcedock.dev/petrbalvin/tensor/internal/engine"
)
// Savitzky-Golay smoothing: every sample is replaced by the value at
// its position of a polynomial of the given order fitted by least
// squares over a symmetric window of samples. Unlike a moving average
// this preserves the moments of the signal up to the fit order, so
// peaks keep their heights and widths instead of being flattened,
// which is why spectroscopy and chromatography standardised on it.
//
// The interior uses one set of precomputed weights, the polynomial
// fit's hat row for the window centre. Samples within half a window
// of an edge use a window truncated at the boundary, fitted at the
// sample's own position, so the output is complete without padding
// and the ends keep the full polynomial treatment at reduced support.
// SavitzkyGolay smooths a rank-1 signal with a Savitzky-Golay
// polynomial filter: window is the (odd) number of samples per fit,
// order the polynomial degree. The standard constraint order ≤
// window/2 applies: a higher degree leaves the truncated edge windows
// with fewer samples than coefficients, so the fit is underdetermined
// and an order above window/2 is an error. A polynomial of degree ≤
// order passes through unchanged.
func SavitzkyGolay(data *core.Array, window, order int) (*core.Array, error) {
if data.Dtype() == core.Complex {
return nil, base.Errf("SavitzkyGolay: complex signals are not supported")
}
if data.NDim() != 1 {
return nil, base.Errf("SavitzkyGolay: needs a rank-1 signal, got shape %s", base.ShapeText(data.Shape()))
}
n := data.Len()
if n == 0 {
return nil, base.Errf("SavitzkyGolay: the signal must not be empty")
}
if window < 3 || window%2 == 0 {
return nil, base.Errf("SavitzkyGolay: window must be an odd number ≥ 3, got %d", window)
}
if window > n {
return nil, base.Errf("SavitzkyGolay: window %d exceeds the signal length %d", window, n)
}
if order < 0 {
return nil, base.Errf("SavitzkyGolay: order must not be negative, got %d", order)
}
if order > window/2 {
return nil, base.Errf("SavitzkyGolay: order %d exceeds window/2 (%d), the edge fits would be underdetermined",
order, window/2)
}
h := window / 2
// Weights for a window with offsets offs, evaluating the fit at
// offset 0 (the centre sample).
weights := func(offs []int) ([]float64, error) {
k := order + 1
m := len(offs)
// Normal equations of the Vandermonde system.
ata := make([][]float64, k)
for i := range k {
ata[i] = make([]float64, k)
for j := range k {
s := 0.0
for _, o := range offs {
s += math.Pow(float64(o), float64(i+j))
}
ata[i][j] = s
}
}
// Huge windows at high order overflow the power sums: a
// non-finite entry would flow through the solver into NaN
// weights published as a smooth signal, so it is refused here.
for i := range k {
for j := range k {
if math.IsInf(ata[i][j], 0) || math.IsNaN(ata[i][j]) {
return nil, base.Errf("SavitzkyGolay: the normal equations overflow for window %d at order %d", 2*h+1, order)
}
}
}
// Right-hand side: the coefficient vector of evaluating at 0
// is e_0, so b_i = 0^i, which is 1 at i = 0 and 0 after.
b := make([]float64, k)
b[0] = 1
sol, err := base.SolveSystem("SavitzkyGolay", ata, [][]float64{b})
if err != nil {
return nil, base.Errf("SavitzkyGolay: %w", err)
}
coef := sol[0]
w := make([]float64, m)
for i, o := range offs {
s := 0.0
for c := range k {
s += coef[c] * math.Pow(float64(o), float64(c))
}
if math.IsInf(s, 0) || math.IsNaN(s) {
return nil, base.Errf("SavitzkyGolay: the weights overflow for window %d at order %d", 2*h+1, order)
}
w[i] = s
}
return w, nil
}
// The interior weights and every edge point's truncated window are
// a pure function of the window and the order, so the whole fit set
// is served from a cache keyed by the two: the power sums, the
// solver and the weight evaluations run once per window-and-order
// instead of once per call.
fits, ferr := savitzkyGolayFits(window, order, h, weights)
if ferr != nil {
return nil, ferr
}
edge := h
out, oerr := core.Zeros(core.Float, []int{n}...)
if oerr != nil {
return nil, oerr
}
outRow := out.RawFloats()
src := widenFloats(data)
// Every output sample owns its own slot and, within half a window
// of an edge, its own precomputed weight fit, so the points split
// across workers once a worker's chunk clears the floor below; the
// per-point summation over the window keeps the serial order. The
// regions split so the interior walk carries no per-point branch:
// each region sees its weights and its slice bounds settled once.
// Every loop is clamped to the worker's own [ps, pe): an edge wider
// than one chunk would otherwise let the edge loops cross a chunk
// boundary, and two workers would write the same output slot at
// once, a data race whose bits happen to agree but whose access the
// memory model and the race detector both refuse.
engine.ParallelMin(n, sgMinPointsPerWorker, func(ps, pe int) {
sweep := func(i int) {
var w []float64
start := max(i-h, 0)
switch {
case i < edge:
w = fits.left[i]
case i >= n-edge:
w = fits.right[n-1-i]
default:
w = fits.full
}
sum := 0.0
row := src[start : start+len(w)]
for k, weight := range w {
sum += weight * row[k]
}
outRow[i] = sum
}
midLo := max(ps, edge)
midHi := min(pe, n-edge)
for i := ps; i < min(midLo, pe); i++ {
sweep(i)
}
if midHi > midLo {
// Interior: the full weights always apply and the window
// never truncates, so the loop runs without the fit choice.
for i := midLo; i < midHi; i++ {
start := i - h
sum := 0.0
row := src[start : start+len(fits.full)]
for k, weight := range fits.full {
sum += weight * row[k]
}
outRow[i] = sum
}
}
for i := max(midHi, ps); i < pe; i++ {
sweep(i)
}
})
return out, nil
}
// sgFitKey names one cached fit set: the window and order that
// determine every weight row in it.
type sgFitKey struct {
window, order int
}
// sgFits holds the interior hat row and the truncated edge fits: for
// left edge point j the window runs over the offsets [-j, h], for the
// mirrored right edge point over [-h, j]. The rows are read-only once
// published.
type sgFits struct {
full []float64
left, right [][]float64
}
// sgFitCacheMax bounds how many window-and-order fit sets stay cached;
// each set is a few hundred floats, so the map stays negligible
// whatever the callers do.
const sgFitCacheMax = 256
var (
sgFitMu sync.RWMutex
sgFitKeys = map[sgFitKey]*sgFits{}
)
// savitzkyGolayFits returns the fit set for a window and order,
// building it through the caller's weights evaluation on first use.
// Every edge point owns a truncated window, fitted at the sample's own
// position, so the parallel sweep cannot fail mid-kernel.
func savitzkyGolayFits(window, order, h int, weights func(offs []int) ([]float64, error)) (*sgFits, error) {
key := sgFitKey{window: window, order: order}
sgFitMu.RLock()
fits, ok := sgFitKeys[key]
sgFitMu.RUnlock()
if ok {
return fits, nil
}
full, err := weights(rangeInts(-h, h))
if err != nil {
return nil, err
}
edge := h
fits = &sgFits{full: full, left: make([][]float64, edge), right: make([][]float64, edge)}
for j := range edge {
lw, lerr := weights(rangeInts(-j, h))
if lerr != nil {
return nil, lerr
}
fits.left[j] = lw
rw, rerr := weights(rangeInts(-h, j))
if rerr != nil {
return nil, rerr
}
fits.right[j] = rw
}
sgFitMu.Lock()
if len(sgFitKeys) < sgFitCacheMax {
sgFitKeys[key] = fits
}
sgFitMu.Unlock()
return fits, nil
}
// sgMinPointsPerWorker is the smallest per-worker chunk of output
// points the Savitzky-Golay sweep splits for: below it a chunk's
// window arithmetic no longer pays the worker spawn cost.
const sgMinPointsPerWorker = 1 << 10
// widenFloats returns the array's elements widened to float64 exactly
// as FloatAt widens them: a contiguous float64 payload aliases the
// array, every other dtype (and any strided array, whose payload order
// is not its element order) converts into a fresh slice. Treat an
// aliased result as read-only.
func widenFloats(a *core.Array) []float64 {
if a.Dtype() == core.Float && !a.Strided() {
return a.RawFloats()
}
out := make([]float64, a.Len())
for i := range out {
out[i] = a.FloatAt(i)
}
return out
}
// rangeInts returns the integers from a to b inclusive.
func rangeInts(a, b int) []int {
out := make([]int, b-a+1)
for i := range out {
out[i] = a + i
}
return out
}
// Digital filter design. The Butterworth family is the flat
// magnitude response: |H|² is monotone in frequency with no ripple on
// either side, which is what a data-cleaning low-pass wants. The
// design runs the classical route: prewarp the cutoff with the
// bilinear transform's tangent, place the analog Butterworth poles on
// the left half-plane circle, map them inside the unit circle, and
// read the coefficients off the factored transfer function. Conjugate
// pole pairs keep every coefficient real; odd orders carry one real
// pole.
// ButterworthLowPass designs an order-th low-pass filter for sample
// rate fs, returning the direct-form coefficients b (numerator) and a
// (denominator, a[0] = 1). The order must be at least 1, fs positive,
// and the cutoff strictly inside (0, fs/2). The response is −3 dB at
// the cutoff and unity at DC.
func ButterworthLowPass(order int, fs, cutoff float64) (b, a []float64, err error) {
return butterworth(order, fs, cutoff, false)
}
// ButterworthHighPass designs the high-pass mirror: the same flat
// response turned around, −3 dB at the same cutoff, unity gain at
// Nyquist. It shares the low-pass poles and carries its zeros at
// z = 1, the bilinear image of the analog prototype's origin zeros.
func ButterworthHighPass(order int, fs, cutoff float64) (b, a []float64, err error) {
return butterworth(order, fs, cutoff, true)
}
// butterworth carries the shared design: the digital low-pass poles,
// zeros and gain as coefficient polynomials, sign-flipped for the
// high-pass.
func butterworth(order int, fs, cutoff float64, highpass bool) (b, a []float64, err error) {
const name = "Butterworth"
if order < 1 {
return nil, nil, base.Errf("%s: the order must be at least 1, got %d", name, order)
}
if !(fs > 0) || math.IsInf(fs, 0) {
return nil, nil, base.Errf("%s: fs must be positive and finite, got %g", name, fs)
}
if !(cutoff > 0) || cutoff >= fs/2 {
return nil, nil, base.Errf("%s: the cutoff must lie in (0, fs/2), got %g for fs %g", name, cutoff, fs)
}
// Prewarped analog cutoff: the bilinear transform maps the digital
// cutoff onto it exactly, so the −3 dB point lands where asked.
w := math.Tan(math.Pi * cutoff / fs)
// Analog poles s_k = w·e^{iθ_k} on the left half-plane circle;
// odd orders place one pole at θ = π, which is real.
a = []float64{1}
for k := range order {
num := 2*k + order + 1
var s complex128
if num == 2*order {
// The odd order's real pole at θ = π: built exactly, not
// through sin(π), whose float noise would detour it into
// the pair branch and fabricate a quadratic factor.
s = complex(-w, 0)
} else {
theta := math.Pi * float64(num) / float64(2*order)
// One Sincos serves both factors, the same pair the
// separate calls produced, so the pole keeps its bits.
sinT, cosT := math.Sincos(theta)
s = complex(w*cosT, w*sinT)
}
z := (1 + s) / (1 - s)
if imag(s) == 0 {
a = mulPolyReal(a, []float64{1, -real(z)})
continue
}
if imag(s) < 0 {
continue // the conjugate partner was consumed on its pass
}
zc := (1 + conj(s)) / (1 - conj(s))
// (1 − z·u)(1 − z̄·u) = 1 − 2Re(z)·u + |z|²·u², real by
// construction; the rounding noise in the imaginary part drops.
a = mulPolyReal(a, []float64{1, -2 * real(z), real(z * zc)})
}
// Zeros: the low-pass puts them all at z = −1 (numerator
// (1+u)^order, DC-normalised); the high-pass shares the very same
// poles and moves the zeros to z = +1 (numerator (1−u)^order,
// Nyquist-normalised). Reflecting the poles by negating z instead
// would answer a different question at the wrong frequencies.
if highpass {
b = binomialCoeffs(order)
for i := range b {
if i%2 == 1 {
b[i] = -b[i]
}
}
k := polyEvalAtMinusOne(a) / math.Pow(2, float64(order))
for i := range b {
b[i] *= k
}
return b, a, nil
}
b = binomialCoeffs(order)
k := polyEvalAtOne(a) / math.Pow(2, float64(order))
for i := range b {
b[i] *= k
}
return b, a, nil
}
// mulPolyReal multiplies two real polynomials in u = z^{-1} (index m
// is the coefficient of u^m).
func mulPolyReal(p, q []float64) []float64 {
out := make([]float64, len(p)+len(q)-1)
for i, pv := range p {
for j, qv := range q {
out[i+j] += pv * qv
}
}
return out
}
// binomialCoeffs returns (1+u)^order as a coefficient list, built by
// exact rational steps so the symmetry C(n,m) = C(n,n−m) survives
// rounding.
func binomialCoeffs(order int) []float64 {
out := make([]float64, order+1)
out[0] = 1
for k := range order {
out[k+1] = out[k] * float64(order-k) / float64(k+1)
}
return out
}
// polyEvalAtMinusOne evaluates a u-polynomial at z = −1: the
// alternating sum of its coefficients.
func polyEvalAtMinusOne(p []float64) float64 {
s := 0.0
for m, v := range p {
if m%2 == 1 {
s -= v
} else {
s += v
}
}
return s
}
// polyEvalAtOne evaluates a u-polynomial at z = 1: the sum of its
// coefficients.
func polyEvalAtOne(p []float64) float64 {
s := 0.0
for _, v := range p {
s += v
}
return s
}
// FilterApply runs a direct-form-II-transposed filter over a rank-1
// real signal: y[n] = Σ b_m·x[n−m] − Σ a_m·y[n−m]. The coefficient
// lists may differ in length (the shorter is zero-padded), a[0] must
// be non-zero and is normalised away, and the output has the same
// length as the input. Float32 and float64 inputs keep their dtype;
// int input widens to float64, because a filtered signal is not
// integral and truncating it would quantise the answer to zero
// wherever the response is small. This is the evaluator the
// Butterworth designs hand their coefficients to, and the sweep
// Filtfilt runs in both directions.
func FilterApply(b, a []float64, x *core.Array) (*core.Array, error) {
const name = "FilterApply"
bc, ac, out, err := filterPrepare(name, b, a, x)
if err != nil {
return nil, err
}
src := widenFloats(x)
dst := make([]float64, x.Len())
filterSweep(bc, ac, src, dst)
outF := out.RawFloats()
if outF == nil || out.Strided() {
for i := range dst {
out.SetFloatAt(i, dst[i])
}
} else {
copy(outF, dst)
}
return out, nil
}
// filterPrepare runs the shared contract of the filter evaluators:
// rank-1 real input, non-empty coefficient lists with a non-zero
// leading denominator, coefficients normalised by a[0] and padded to
// one common length, and an output array keeping the input's float
// dtype. The normalised coefficients and the output come back
// together.
func filterPrepare(name string, b, a []float64, x *core.Array) (bc, ac []float64, out *core.Array, err error) {
if x.NDim() != 1 {
return nil, nil, nil, base.Errf("%s: needs a rank-1 signal, got shape %s", name, base.ShapeText(x.Shape()))
}
if x.Dtype() == core.Complex {
return nil, nil, nil, base.Errf("%s: complex signals are not supported", name)
}
if len(b) == 0 || len(a) == 0 {
return nil, nil, nil, base.Errf("%s: the coefficient lists must not be empty", name)
}
if a[0] == 0 {
return nil, nil, nil, base.Errf("%s: a[0] must be non-zero", name)
}
n := max(len(b), len(a))
bc = make([]float64, n)
ac = make([]float64, n)
for i := range b {
bc[i] = b[i] / a[0]
}
ac[0] = 1
for i := 1; i < len(a); i++ {
ac[i] = a[i] / a[0]
}
outDT := x.Dtype()
if outDT != core.Float && outDT != core.Float32 {
outDT = core.Float
}
out, oerr := core.Zeros(outDT, x.Len())
if oerr != nil {
return nil, nil, nil, oerr
}
return bc, ac, out, nil
}
// filterSweep is the direct-form-II-transposed kernel both filter
// evaluators run: one pass of the normalised coefficients over src,
// written to dst, which must not overlap src. Transposed delays:
// coefficient indices run 0..n−1, so the recurrence carries exactly
// n−1 registers; the last one takes no +state term, which is where a
// phantom extra delay would sneak in.
func filterSweep(bc, ac, src, dst []float64) {
n := len(bc)
state := make([]float64, max(n-1, 1))
for i := range src {
xv := src[i]
yv := bc[0] * xv
if n > 1 {
yv += state[0]
for j := 1; j < n-1; j++ {
state[j-1] = bc[j]*xv - ac[j]*yv + state[j]
}
state[n-2] = bc[n-1]*xv - ac[n-1]*yv
}
dst[i] = yv
}
}