Files
tensor/signal/filtfilt.go
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

88 lines
3.4 KiB
Go
Raw Permalink 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 (
"sourcedock.dev/petrbalvin/tensor/internal/base"
"sourcedock.dev/petrbalvin/tensor/internal/core"
)
// Zero-phase filtering. A causal IIR filter delays every
// feature by its group delay; running the same filter backwards over
// its own output doubles the magnitude response and cancels the phase
// exactly, because the reversed pass carries the conjugated transfer
// function. The cost is the edge question: the first pass starts from
// rest against a signal that did not, and its start-up transient would
// otherwise bleed into the answer's first samples.
// Filtfilt filters the rank-1 real signal x forwards and backwards
// with the same transfer function the filter designs hand out (b over
// a, a[0] non-zero and normalised away), and returns a result the
// length of x whose magnitude response is the square of the one-pass
// filter's and whose phase is zero: a sinusoid in the passband comes
// out aligned with its input, not lagged, and the group delay at
// every frequency is 0 samples. The dtype contract is FilterApply's:
// float32 and float64 keep their dtype, other real dtypes widen to
// float64.
//
// Edge initialisation: both ends of the signal are extended by an
// even reflection of pad = 3·(nfilt−1) samples (nfilt the longer
// coefficient list), the edge sample not repeated, so the extension
// is continuous in value at both seams. The pad length is the
// standard three times the filter's memory: for a stable filter the
// start-up transient decays like the impulse response tail, which
// 3·(nfilt−1) samples of a direct-form kernel drive below the rounding
// floor for every design this package produces. The forward pass runs
// over the padded signal, the signal is time-reversed, the second
// pass runs, and the pad region of both ends is cropped away, so the
// residual seam error, a slope kink the reflection cannot hide from a
// filter that differentiates, stays outside the returned range. A
// signal no longer than the pad (length ≤ 3·(nfilt−1)) leaves nothing
// to return once both transient regions are excluded and is refused.
func Filtfilt(b, a []float64, x *core.Array) (*core.Array, error) {
const name = "Filtfilt"
bc, ac, out, err := filterPrepare(name, b, a, x)
if err != nil {
return nil, err
}
n := x.Len()
nfilt := max(len(b), len(a))
pad := 3 * (nfilt - 1)
if n <= pad {
return nil, base.Errf("%s: the length-%d signal must exceed the pad length 3·(%d−1) = %d this filter needs",
name, n, nfilt, pad)
}
src := widenFloats(x)
// The reflected extension: left pad holds x[pad], x[pad−1], …,
// x[1] in reverse order, the right pad mirrors it, and the seam at
// either end repeats no sample (whole-sample symmetric even
// reflection).
padded := make([]float64, n+2*pad)
copy(padded[pad:pad+n], src)
for k := range pad {
padded[k] = src[pad-k]
padded[pad+n+k] = src[n-2-k]
}
p := len(padded)
fwd := make([]float64, p)
filterSweep(bc, ac, padded, fwd)
rev := make([]float64, p)
for i := range p {
rev[i] = fwd[p-1-i]
}
back := make([]float64, p)
filterSweep(bc, ac, rev, back)
outF := out.RawFloats()
if outF == nil || out.Strided() {
for t := range n {
out.SetFloatAt(t, back[p-1-pad-t])
}
} else {
for t := range n {
outF[t] = back[p-1-pad-t]
}
}
return out, nil
}