196 lines
5.4 KiB
Go
196 lines
5.4 KiB
Go
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
|||
|
|
// SPDX-License-Identifier: MIT
|
||
|
|
|
||
|
|
package signal_test
|
||
|
|
|
||
|
|
// Runnable godoc examples for the flagship workflows. Every one of them
|
||
|
|
// runs on a fixed input and prints a fixed result, so `go test` checks
|
||
|
|
// the documentation against the code.
|
||
|
|
|
||
|
|
import (
|
||
|
|
"fmt"
|
||
|
|
"log"
|
||
|
|
"math"
|
||
|
|
|
||
|
|
tensor "sourcedock.dev/petrbalvin/tensor"
|
||
|
|
"sourcedock.dev/petrbalvin/tensor/signal"
|
||
|
|
)
|
||
|
|
|
||
|
|
// A two-tone signal at 4 Hz and 12 Hz, sampled at 64 Hz: Welch's
|
||
|
|
// averaged, windowed estimate puts both tones on their own bins.
|
||
|
|
func ExampleWelchPSD() {
|
||
|
|
const n, fs = 64, 64.0
|
||
|
|
raw := make([]float64, n)
|
||
|
|
for i := range raw {
|
||
|
|
t := float64(i) / fs
|
||
|
|
raw[i] = math.Cos(2*math.Pi*4*t) + 0.5*math.Cos(2*math.Pi*12*t)
|
||
|
|
}
|
||
|
|
x, err := tensor.FromFloats(raw, n)
|
||
|
|
if err != nil {
|
||
|
|
log.Fatal(err)
|
||
|
|
}
|
||
|
|
freqs, psd, err := signal.WelchPSD(x, fs, 32, 16, "hann")
|
||
|
|
if err != nil {
|
||
|
|
log.Fatal(err)
|
||
|
|
}
|
||
|
|
peak := 0
|
||
|
|
for k := range psd.Len() {
|
||
|
|
if psd.FloatAt(k) > psd.FloatAt(peak) {
|
||
|
|
peak = k
|
||
|
|
}
|
||
|
|
}
|
||
|
|
fmt.Printf("peak %.0f Hz, %.3f power, %d bins\n",
|
||
|
|
freqs.FloatAt(peak), psd.FloatAt(peak), psd.Len())
|
||
|
|
// Output: peak 4 Hz, 0.167 power, 17 bins
|
||
|
|
}
|
||
|
|
|
||
|
|
// A 5 Hz tone in a 20 Hz passband: the one-pass filter lags the tone,
|
||
|
|
// the forward-and-backward sweep leaves it where it was.
|
||
|
|
func ExampleFiltfilt() {
|
||
|
|
const n, fs, f = 200, 100.0, 5.0
|
||
|
|
raw := make([]float64, n)
|
||
|
|
for i := range raw {
|
||
|
|
raw[i] = math.Sin(2 * math.Pi * f * float64(i) / fs)
|
||
|
|
}
|
||
|
|
x, err := tensor.FromFloats(raw, n)
|
||
|
|
if err != nil {
|
||
|
|
log.Fatal(err)
|
||
|
|
}
|
||
|
|
b, a, err := signal.ButterworthLowPass(2, fs, 20)
|
||
|
|
if err != nil {
|
||
|
|
log.Fatal(err)
|
||
|
|
}
|
||
|
|
once, err := signal.FilterApply(b, a, x)
|
||
|
|
if err != nil {
|
||
|
|
log.Fatal(err)
|
||
|
|
}
|
||
|
|
twice, err := signal.Filtfilt(b, a, x)
|
||
|
|
if err != nil {
|
||
|
|
log.Fatal(err)
|
||
|
|
}
|
||
|
|
peak := func(y *tensor.Array) int {
|
||
|
|
at := 0
|
||
|
|
for i := range y.Len() {
|
||
|
|
if y.FloatAt(i) > y.FloatAt(at) {
|
||
|
|
at = i
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return at
|
||
|
|
}
|
||
|
|
fmt.Printf("input peaks at %d, one pass at %d, filtfilt at %d\n",
|
||
|
|
peak(x), peak(once), peak(twice))
|
||
|
|
// Output: input peaks at 5, one pass at 6, filtfilt at 5
|
||
|
|
}
|
||
|
|
|
||
|
|
// The DB2 transform packs the coefficients as [A, D2, D1]. The periodic
|
||
|
|
// boundary keeps the energy, so the inverse returns the signal.
|
||
|
|
func ExampleDaubechiesDWT() {
|
||
|
|
const levels = 2
|
||
|
|
x, err := tensor.FromFloats([]float64{1, 2, 3, 4, 5, 6, 7, 8}, 8)
|
||
|
|
if err != nil {
|
||
|
|
log.Fatal(err)
|
||
|
|
}
|
||
|
|
coef, err := signal.DaubechiesDWT(x, signal.DB2, levels, signal.DWTPeriodic)
|
||
|
|
if err != nil {
|
||
|
|
log.Fatal(err)
|
||
|
|
}
|
||
|
|
back, err := signal.DaubechiesIDWT(coef, signal.DB2, levels, signal.DWTPeriodic)
|
||
|
|
if err != nil {
|
||
|
|
log.Fatal(err)
|
||
|
|
}
|
||
|
|
energy := func(v *tensor.Array) float64 {
|
||
|
|
total := 0.0
|
||
|
|
for i := range v.Len() {
|
||
|
|
total += v.FloatAt(i) * v.FloatAt(i)
|
||
|
|
}
|
||
|
|
return total
|
||
|
|
}
|
||
|
|
worst := 0.0
|
||
|
|
for i := range x.Len() {
|
||
|
|
worst = math.Max(worst, math.Abs(back.FloatAt(i)-x.FloatAt(i)))
|
||
|
|
}
|
||
|
|
fmt.Printf("%d levels, %d coefficients, round trip exact: %v\n",
|
||
|
|
levels, coef.Len(), worst < 1e-12)
|
||
|
|
fmt.Printf("energy kept to %.3f\n", energy(coef)/energy(x))
|
||
|
|
// Output: 2 levels, 8 coefficients, round trip exact: true
|
||
|
|
// energy kept to 1.000
|
||
|
|
}
|
||
|
|
|
||
|
|
// The Hilbert envelope of a tone whose amplitude swings at 1 Hz: the
|
||
|
|
// modulus of the analytic signal traces the swing without smoothing lag.
|
||
|
|
func ExampleEnvelope() {
|
||
|
|
const n, fs = 256, 128.0
|
||
|
|
raw := make([]float64, n)
|
||
|
|
for i := range raw {
|
||
|
|
t := float64(i) / fs
|
||
|
|
// A 20 Hz carrier under a 1 Hz amplitude swing.
|
||
|
|
raw[i] = (1 + 0.5*math.Cos(2*math.Pi*t)) * math.Cos(2*math.Pi*20*t)
|
||
|
|
}
|
||
|
|
x, err := tensor.FromFloats(raw, n)
|
||
|
|
if err != nil {
|
||
|
|
log.Fatal(err)
|
||
|
|
}
|
||
|
|
env, err := signal.Envelope(x)
|
||
|
|
if err != nil {
|
||
|
|
log.Fatal(err)
|
||
|
|
}
|
||
|
|
// The swing peaks at t = 0 s and t = 1 s, and bottoms at t = 0.5 s.
|
||
|
|
at := func(seconds float64) int { return int(seconds * fs) }
|
||
|
|
fmt.Printf("t=0 s %.3f, t=0.5 s %.3f, t=1 s %.3f\n",
|
||
|
|
env.FloatAt(at(0)), env.FloatAt(at(0.5)), env.FloatAt(at(1)))
|
||
|
|
// Output: t=0 s 1.500, t=0.5 s 0.500, t=1 s 1.500
|
||
|
|
}
|
||
|
|
|
||
|
|
// A constant position observed three times: the linear Kalman filter
|
||
|
|
// fuses each measurement into the running estimate and reports the
|
||
|
|
// filtered means, one row per measurement.
|
||
|
|
func ExampleKalmanFilter() {
|
||
|
|
z, err := tensor.FromFloats([]float64{1, 2, 3}, 3)
|
||
|
|
if err != nil {
|
||
|
|
log.Fatal(err)
|
||
|
|
}
|
||
|
|
f, err := tensor.FromFloats([]float64{1}, 1, 1)
|
||
|
|
if err != nil {
|
||
|
|
log.Fatal(err)
|
||
|
|
}
|
||
|
|
h, err := tensor.FromFloats([]float64{1}, 1, 1)
|
||
|
|
if err != nil {
|
||
|
|
log.Fatal(err)
|
||
|
|
}
|
||
|
|
res, err := signal.KalmanFilter(z, f, h, signal.KalmanOptions{})
|
||
|
|
if err != nil {
|
||
|
|
log.Fatal(err)
|
||
|
|
}
|
||
|
|
fmt.Printf("states %.3f %.3f %.3f\n", res.States.FloatAt(0), res.States.FloatAt(1), res.States.FloatAt(2))
|
||
|
|
fmt.Printf("log likelihood %.2f\n", res.LogLikelihood)
|
||
|
|
// Output: states 0.500 1.000 1.500
|
||
|
|
// log likelihood -5.95
|
||
|
|
}
|
||
|
|
|
||
|
|
// An AR(1) series built from a fixed linear congruential driver: the
|
||
|
|
// Yule-Walker fit recovers the coefficient that generated it.
|
||
|
|
func ExampleEstimateAR() {
|
||
|
|
const n = 512
|
||
|
|
raw := make([]float64, n)
|
||
|
|
seed := 1.0
|
||
|
|
for i := range raw {
|
||
|
|
// The minimal-standard generator: deterministic, so the
|
||
|
|
// example never calls a random source.
|
||
|
|
seed = math.Mod(16807*seed, 2147483647)
|
||
|
|
raw[i] = seed/2147483647 - 0.5
|
||
|
|
if i > 0 {
|
||
|
|
raw[i] += 0.5 * raw[i-1]
|
||
|
|
}
|
||
|
|
}
|
||
|
|
x, err := tensor.FromFloats(raw, n)
|
||
|
|
if err != nil {
|
||
|
|
log.Fatal(err)
|
||
|
|
}
|
||
|
|
res, err := signal.EstimateAR(x, 1)
|
||
|
|
if err != nil {
|
||
|
|
log.Fatal(err)
|
||
|
|
}
|
||
|
|
fmt.Printf("AR(%d): phi_1 %.3f\n", res.P, res.AR[0])
|
||
|
|
// Output: AR(1): phi_1 0.495
|
||
|
|
}
|