Files

542 lines
18 KiB
Go
Raw Permalink 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"
"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))
if err := designCoefficientsFinite(name, order, k, b, a); err != nil {
return nil, nil, err
}
2026-09-03 10:00:00 +02:00
for i := range b {
b[i] *= k
}
return b, a, nil
}
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
}
2026-09-03 10:00:00 +02:00
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
}
2026-09-03 10:00:00 +02:00
// 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
}
}